From 6d98b33c563d2d78ebb86d28e3b92b47f1725c0e Mon Sep 17 00:00:00 2001 From: Stefan Benz <46600784+stebenz@users.noreply.github.com> Date: Tue, 29 Jul 2025 15:56:21 +0200 Subject: [PATCH 01/16] feat: organization settings for user uniqueness (#10246) # Which Problems Are Solved Currently the username uniqueness is on instance level, we want to achieve a way to set it at organization level. # How the Problems Are Solved Addition of endpoints and a resource on organization level, where this setting can be managed. If nothing it set, the uniqueness is expected to be at instance level, where only users with instance permissions should be able to change this setting. # Additional Changes None # Additional Context Includes #10086 Closes #9964 --------- Co-authored-by: Marco A. --- cmd/start/start.go | 2 +- internal/api/grpc/settings/v2/server.go | 5 +- .../v2beta/integration_test/query_test.go | 281 ++++++++ .../v2beta/integration_test/settings_test.go | 278 ++++++++ internal/api/grpc/settings/v2beta/query.go | 114 ++++ internal/api/grpc/settings/v2beta/server.go | 13 +- internal/api/grpc/settings/v2beta/settings.go | 28 + .../settings/v2beta/settings_converter.go | 7 + internal/command/instance_policy_domain.go | 11 +- .../command/instance_policy_domain_test.go | 335 +++++++++- internal/command/instance_test.go | 3 + internal/command/org.go | 10 +- internal/command/org_domain_test.go | 88 +++ internal/command/org_policy_domain.go | 40 +- internal/command/org_policy_domain_test.go | 608 ++++++++++++++++-- internal/command/org_test.go | 5 + internal/command/organization_settings.go | 140 ++++ .../command/organization_settings_model.go | 245 +++++++ .../command/organization_settings_test.go | 542 ++++++++++++++++ internal/command/policy_org_model.go | 15 +- internal/command/user.go | 30 +- internal/command/user_human.go | 29 +- internal/command/user_human_test.go | 286 ++++++++ internal/command/user_machine.go | 6 +- internal/command/user_machine_test.go | 55 ++ internal/command/user_test.go | 234 ++++--- internal/command/user_v2.go | 8 +- internal/command/user_v2_human.go | 9 +- internal/command/user_v2_human_test.go | 201 +++++- internal/command/user_v2_machine_test.go | 50 +- internal/command/user_v2_model_test.go | 1 + internal/command/user_v2_test.go | 123 ++-- internal/command/user_v2_username.go | 13 +- internal/domain/organization_settings.go | 19 + internal/domain/permission.go | 3 + internal/integration/client.go | 21 + internal/query/administrators.go | 4 +- internal/query/organization_settings.go | 196 ++++++ internal/query/organization_settings_test.go | 180 ++++++ .../query/projection/organization_settings.go | 141 ++++ .../projection/organization_settings_test.go | 154 +++++ internal/query/projection/projection.go | 3 + internal/repository/org/org.go | 30 +- .../organization_settings/aggregate.go | 23 + .../organization_settings/eventstore.go | 8 + .../organization_settings/organization.go | 96 +++ internal/repository/policy/policy_domain.go | 13 +- internal/repository/user/human.go | 84 +-- internal/repository/user/machine.go | 18 +- internal/repository/user/user.go | 88 ++- proto/zitadel/filter/v2/filter.proto | 20 + .../project/v2beta/project_service.proto | 2 +- .../settings/v2/settings_service.proto | 16 +- .../v2beta/organization_settings.proto | 55 ++ .../settings/v2beta/settings_service.proto | 148 ++++- 55 files changed, 4785 insertions(+), 352 deletions(-) create mode 100644 internal/api/grpc/settings/v2beta/integration_test/query_test.go create mode 100644 internal/api/grpc/settings/v2beta/query.go create mode 100644 internal/command/organization_settings.go create mode 100644 internal/command/organization_settings_model.go create mode 100644 internal/command/organization_settings_test.go create mode 100644 internal/domain/organization_settings.go create mode 100644 internal/query/organization_settings.go create mode 100644 internal/query/organization_settings_test.go create mode 100644 internal/query/projection/organization_settings.go create mode 100644 internal/query/projection/organization_settings_test.go create mode 100644 internal/repository/organization_settings/aggregate.go create mode 100644 internal/repository/organization_settings/eventstore.go create mode 100644 internal/repository/organization_settings/organization.go create mode 100644 proto/zitadel/settings/v2beta/organization_settings.proto diff --git a/cmd/start/start.go b/cmd/start/start.go index 9c1e2a4d28..adbac7f822 100644 --- a/cmd/start/start.go +++ b/cmd/start/start.go @@ -482,7 +482,7 @@ func startAPIs( if err := apis.RegisterService(ctx, session_v2beta.CreateServer(commands, queries, permissionCheck)); err != nil { return nil, err } - if err := apis.RegisterService(ctx, settings_v2beta.CreateServer(commands, queries)); err != nil { + if err := apis.RegisterService(ctx, settings_v2beta.CreateServer(config.SystemDefaults, commands, queries, permissionCheck)); err != nil { return nil, err } if err := apis.RegisterService(ctx, org_v2beta.CreateServer(config.SystemDefaults, commands, queries, permissionCheck)); err != nil { diff --git a/internal/api/grpc/settings/v2/server.go b/internal/api/grpc/settings/v2/server.go index bfaec17fc2..3f7c2a02ec 100644 --- a/internal/api/grpc/settings/v2/server.go +++ b/internal/api/grpc/settings/v2/server.go @@ -19,8 +19,9 @@ import ( var _ settingsconnect.SettingsServiceHandler = (*Server)(nil) type Server struct { - command *command.Commands - query *query.Queries + command *command.Commands + query *query.Queries + assetsAPIDomain func(context.Context) string } diff --git a/internal/api/grpc/settings/v2beta/integration_test/query_test.go b/internal/api/grpc/settings/v2beta/integration_test/query_test.go new file mode 100644 index 0000000000..7886bac539 --- /dev/null +++ b/internal/api/grpc/settings/v2beta/integration_test/query_test.go @@ -0,0 +1,281 @@ +//go:build integration + +package settings_test + +import ( + "context" + "testing" + "time" + + "github.com/brianvoe/gofakeit/v6" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zitadel/zitadel/internal/integration" + "github.com/zitadel/zitadel/pkg/grpc/filter/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/settings/v2beta" +) + +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, gofakeit.Company(), gofakeit.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, gofakeit.Company(), gofakeit.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, gofakeit.Company(), gofakeit.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, gofakeit.Company(), gofakeit.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, gofakeit.Company(), gofakeit.Email()) + settingsResp1 := instance.SetOrganizationSettings(iamOwnerCtx, t, orgResp1.GetOrganizationId(), true) + orgResp2 := instance.CreateOrganization(iamOwnerCtx, gofakeit.Company(), gofakeit.Email()) + settingsResp2 := instance.SetOrganizationSettings(iamOwnerCtx, t, orgResp2.GetOrganizationId(), true) + orgResp3 := instance.CreateOrganization(iamOwnerCtx, gofakeit.Company(), gofakeit.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, gofakeit.Company(), gofakeit.Email()) + instance.SetOrganizationSettings(iamOwnerCtx, t, orgResp1.GetOrganizationId(), false) + orgResp2 := instance.CreateOrganization(iamOwnerCtx, gofakeit.Company(), gofakeit.Email()) + settingsResp2 := instance.SetOrganizationSettings(iamOwnerCtx, t, orgResp2.GetOrganizationId(), true) + orgResp3 := instance.CreateOrganization(iamOwnerCtx, gofakeit.Company(), gofakeit.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.SettingsV2beta.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/v2beta/integration_test/settings_test.go b/internal/api/grpc/settings/v2beta/integration_test/settings_test.go index d5c1914ba9..4e028fb6d5 100644 --- a/internal/api/grpc/settings/v2beta/integration_test/settings_test.go +++ b/internal/api/grpc/settings/v2beta/integration_test/settings_test.go @@ -7,6 +7,8 @@ import ( "testing" "time" + "github.com/brianvoe/gofakeit/v6" + "github.com/muhlemmer/gu" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "google.golang.org/protobuf/types/known/timestamppb" @@ -178,3 +180,279 @@ func TestServer_SetSecuritySettings(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, gofakeit.Company(), gofakeit.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, gofakeit.Company(), gofakeit.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, gofakeit.Company(), gofakeit.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.SettingsV2beta.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, gofakeit.Company(), gofakeit.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, gofakeit.Company(), gofakeit.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, gofakeit.Company(), gofakeit.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, gofakeit.Company(), gofakeit.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.SettingsV2beta.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/v2beta/query.go b/internal/api/grpc/settings/v2beta/query.go new file mode 100644 index 0000000000..ac07031ed9 --- /dev/null +++ b/internal/api/grpc/settings/v2beta/query.go @@ -0,0 +1,114 @@ +package settings + +import ( + "context" + + "connectrpc.com/connect" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/api/grpc/filter/v2beta" + "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/internal/zerrors" + filter_pb "github.com/zitadel/zitadel/pkg/grpc/filter/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/settings/v2beta" +) + +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/v2beta/server.go b/internal/api/grpc/settings/v2beta/server.go index a8200a7216..3eea310006 100644 --- a/internal/api/grpc/settings/v2beta/server.go +++ b/internal/api/grpc/settings/v2beta/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" settings "github.com/zitadel/zitadel/pkg/grpc/settings/v2beta" "github.com/zitadel/zitadel/pkg/grpc/settings/v2beta/settingsconnect" @@ -19,20 +21,27 @@ 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 } type Config struct{} 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/v2beta/settings.go b/internal/api/grpc/settings/v2beta/settings.go index 53d2c37c32..7388c0449a 100644 --- a/internal/api/grpc/settings/v2beta/settings.go +++ b/internal/api/grpc/settings/v2beta/settings.go @@ -167,3 +167,31 @@ func (s *Server) SetSecuritySettings(ctx context.Context, req *connect.Request[s Details: object.DomainToDetailsPb(details), }), 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/v2beta/settings_converter.go b/internal/api/grpc/settings/v2beta/settings_converter.go index 2b20e738e1..ad4ad2ebab 100644 --- a/internal/api/grpc/settings/v2beta/settings_converter.go +++ b/internal/api/grpc/settings/v2beta/settings_converter.go @@ -243,3 +243,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/internal/command/instance_policy_domain.go b/internal/command/instance_policy_domain.go index 969bc219fe..449475f8d6 100644 --- a/internal/command/instance_policy_domain.go +++ b/internal/command/instance_policy_domain.go @@ -130,11 +130,20 @@ func prepareChangeDefaultDomainPolicy( // loop over all found organisations to get their usernames // and to compute the username changed events for _, orgID := range orgsWriteModel.OrgIDs { + organizationScopedUsernames, err := checkOrganizationScopedUsernames(ctx, filter, a.ID, nil) + if err != nil { + return nil, err + } + usersWriteModel, err := domainPolicyUsernames(ctx, filter, orgID) if err != nil { return nil, err } - cmds = append(cmds, usersWriteModel.NewUsernameChangedEvents(ctx, userLoginMustBeDomain)...) + cmds = append(cmds, usersWriteModel.NewUsernameChangedEvents(ctx, + userLoginMustBeDomain, + organizationScopedUsernames, + writeModel.UserLoginMustBeDomain, + )...) } return cmds, nil }, nil diff --git a/internal/command/instance_policy_domain_test.go b/internal/command/instance_policy_domain_test.go index 745d4a7efe..17c975b800 100644 --- a/internal/command/instance_policy_domain_test.go +++ b/internal/command/instance_policy_domain_test.go @@ -19,7 +19,7 @@ import ( func TestCommandSide_AddDefaultDomainPolicy(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(t *testing.T) *eventstore.Eventstore } type args struct { ctx context.Context @@ -40,8 +40,7 @@ func TestCommandSide_AddDefaultDomainPolicy(t *testing.T) { { name: "domain policy already existing, already exists error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( instance.NewDomainPolicyAddedEvent(context.Background(), @@ -67,8 +66,7 @@ func TestCommandSide_AddDefaultDomainPolicy(t *testing.T) { { name: "add policy,ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), expectPush( instance.NewDomainPolicyAddedEvent(context.Background(), @@ -96,7 +94,7 @@ func TestCommandSide_AddDefaultDomainPolicy(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), } got, err := r.AddDefaultDomainPolicy(tt.args.ctx, tt.args.userLoginMustBeDomain, tt.args.validateOrgDomains, tt.args.smtpSenderAddressMatchesInstanceDomain) if tt.res.err == nil { @@ -114,7 +112,7 @@ func TestCommandSide_AddDefaultDomainPolicy(t *testing.T) { func TestCommandSide_ChangeDefaultDomainPolicy(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(t *testing.T) *eventstore.Eventstore } type args struct { ctx context.Context @@ -135,8 +133,7 @@ func TestCommandSide_ChangeDefaultDomainPolicy(t *testing.T) { { name: "domain policy not existing, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), }, @@ -153,8 +150,7 @@ func TestCommandSide_ChangeDefaultDomainPolicy(t *testing.T) { { name: "no changes, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( instance.NewDomainPolicyAddedEvent(context.Background(), @@ -180,8 +176,7 @@ func TestCommandSide_ChangeDefaultDomainPolicy(t *testing.T) { { name: "change, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( instance.NewDomainPolicyAddedEvent(context.Background(), @@ -236,6 +231,7 @@ func TestCommandSide_ChangeDefaultDomainPolicy(t *testing.T) { ), // domainPolicyUsernames for each org // org1 + expectFilterOrganizationSettings("org1", false, false), expectFilter( eventFromEventPusher( org.NewDomainPrimarySetEvent( @@ -266,6 +262,7 @@ func TestCommandSide_ChangeDefaultDomainPolicy(t *testing.T) { ), ), // org3 + expectFilterOrganizationSettings("org3", false, false), expectFilter( eventFromEventPusher( org.NewDomainPrimarySetEvent( @@ -302,14 +299,16 @@ func TestCommandSide_ChangeDefaultDomainPolicy(t *testing.T) { "user1", "user1@org1.com", false, - user.UsernameChangedEventWithPolicyChange(), + false, + user.UsernameChangedEventWithPolicyChange(true), ), user.NewUsernameChangedEvent(context.Background(), &user.NewAggregate("user1", "org3").Aggregate, "user1", "user1@org3.com", false, - user.UsernameChangedEventWithPolicyChange(), + false, + user.UsernameChangedEventWithPolicyChange(true), ), ), ), @@ -326,11 +325,315 @@ func TestCommandSide_ChangeDefaultDomainPolicy(t *testing.T) { }, }, }, + { + name: "change, organization scoped usernames, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + instance.NewDomainPolicyAddedEvent(context.Background(), + &instance.NewAggregate("INSTANCE").Aggregate, + true, + true, + true, + ), + ), + ), + // domainPolicyOrgs + expectFilter( + eventFromEventPusher( + org.NewOrgAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + "org1", + ), + ), + eventFromEventPusher( + org.NewOrgAddedEvent(context.Background(), + &org.NewAggregate("org2").Aggregate, + "org2", + ), + ), + eventFromEventPusher( + org.NewDomainPolicyAddedEvent(context.Background(), + &org.NewAggregate("org2").Aggregate, + false, + false, + false, + ), + ), + eventFromEventPusher( + org.NewOrgAddedEvent(context.Background(), + &org.NewAggregate("org3").Aggregate, + "org3", + ), + ), + eventFromEventPusher( + org.NewDomainPolicyAddedEvent(context.Background(), + &org.NewAggregate("org3").Aggregate, + false, + false, + false, + ), + ), + eventFromEventPusher( + org.NewDomainPolicyRemovedEvent(context.Background(), + &org.NewAggregate("org3").Aggregate, + ), + ), + ), + // domainPolicyUsernames for each org + // org1 + expectFilterOrganizationSettings("org1", true, true), + expectFilter( + eventFromEventPusher( + org.NewDomainPrimarySetEvent( + context.Background(), + &org.NewAggregate("org1").Aggregate, + "org1.com", + ), + ), + eventFromEventPusher( + org.NewDomainPrimarySetEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + "org1.com", + ), + ), + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "user1", + "firstname", + "lastname", + "nickname", + "displayname", + language.English, + domain.GenderUnspecified, + "user1@org1.com", + false, + ), + ), + ), + // org3 + expectFilterOrganizationSettings("org3", true, true), + expectFilter( + eventFromEventPusher( + org.NewDomainPrimarySetEvent( + context.Background(), + &org.NewAggregate("org3").Aggregate, + "org3.com", + ), + ), + eventFromEventPusher( + org.NewDomainPrimarySetEvent(context.Background(), + &org.NewAggregate("org3").Aggregate, + "org3.com", + ), + ), + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org3").Aggregate, + "user1", + "firstname", + "lastname", + "nickname", + "displayname", + language.English, + domain.GenderUnspecified, + "user1@org3.com", + false, + ), + ), + ), + expectPush( + newDefaultDomainPolicyChangedEvent(context.Background(), false, false, false), + user.NewUsernameChangedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "user1", + "user1@org1.com", + false, + true, + user.UsernameChangedEventWithPolicyChange(true), + ), + user.NewUsernameChangedEvent(context.Background(), + &user.NewAggregate("user1", "org3").Aggregate, + "user1", + "user1@org3.com", + false, + true, + user.UsernameChangedEventWithPolicyChange(true), + ), + ), + ), + }, + args: args{ + ctx: authz.WithInstanceID(context.Background(), "INSTANCE"), + userLoginMustBeDomain: false, + validateOrgDomains: false, + smtpSenderAddressMatchesInstanceDomain: false, + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "INSTANCE", + }, + }, + }, + { + name: "change, organization scoped usernames, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + instance.NewDomainPolicyAddedEvent(context.Background(), + &instance.NewAggregate("INSTANCE").Aggregate, + false, + true, + true, + ), + ), + ), + // domainPolicyOrgs + expectFilter( + eventFromEventPusher( + org.NewOrgAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + "org1", + ), + ), + eventFromEventPusher( + org.NewOrgAddedEvent(context.Background(), + &org.NewAggregate("org2").Aggregate, + "org2", + ), + ), + eventFromEventPusher( + org.NewDomainPolicyAddedEvent(context.Background(), + &org.NewAggregate("org2").Aggregate, + true, + false, + false, + ), + ), + eventFromEventPusher( + org.NewOrgAddedEvent(context.Background(), + &org.NewAggregate("org3").Aggregate, + "org3", + ), + ), + eventFromEventPusher( + org.NewDomainPolicyAddedEvent(context.Background(), + &org.NewAggregate("org3").Aggregate, + true, + false, + false, + ), + ), + eventFromEventPusher( + org.NewDomainPolicyRemovedEvent(context.Background(), + &org.NewAggregate("org3").Aggregate, + ), + ), + ), + // domainPolicyUsernames for each org + // org1 + expectFilterOrganizationSettings("org1", true, true), + expectFilter( + eventFromEventPusher( + org.NewDomainPrimarySetEvent( + context.Background(), + &org.NewAggregate("org1").Aggregate, + "org1.com", + ), + ), + eventFromEventPusher( + org.NewDomainPrimarySetEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + "org1.com", + ), + ), + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "user1@org1.com", + "firstname", + "lastname", + "nickname", + "displayname", + language.English, + domain.GenderUnspecified, + "user1@org1.com", + false, + ), + ), + ), + // org3 + expectFilterOrganizationSettings("org3", true, true), + expectFilter( + eventFromEventPusher( + org.NewDomainPrimarySetEvent( + context.Background(), + &org.NewAggregate("org3").Aggregate, + "org3.com", + ), + ), + eventFromEventPusher( + org.NewDomainPrimarySetEvent(context.Background(), + &org.NewAggregate("org3").Aggregate, + "org3.com", + ), + ), + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org3").Aggregate, + "user1@org3.com", + "firstname", + "lastname", + "nickname", + "displayname", + language.English, + domain.GenderUnspecified, + "user1@org3.com", + true, + ), + ), + ), + expectPush( + newDefaultDomainPolicyChangedEvent(context.Background(), true, false, false), + user.NewUsernameChangedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "user1@org1.com", + "user1@org1.com", + true, + true, + user.UsernameChangedEventWithPolicyChange(false), + ), + user.NewUsernameChangedEvent(context.Background(), + &user.NewAggregate("user1", "org3").Aggregate, + "user1@org3.com", + "user1@org3.com", + true, + true, + user.UsernameChangedEventWithPolicyChange(true), + ), + ), + ), + }, + args: args{ + ctx: authz.WithInstanceID(context.Background(), "INSTANCE"), + userLoginMustBeDomain: true, + validateOrgDomains: false, + smtpSenderAddressMatchesInstanceDomain: false, + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "INSTANCE", + }, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), } got, err := r.ChangeDefaultDomainPolicy(tt.args.ctx, tt.args.userLoginMustBeDomain, tt.args.validateOrgDomains, tt.args.smtpSenderAddressMatchesInstanceDomain) if tt.res.err == nil { diff --git a/internal/command/instance_test.go b/internal/command/instance_test.go index b40bba19af..0eca521a7f 100644 --- a/internal/command/instance_test.go +++ b/internal/command/instance_test.go @@ -478,6 +478,7 @@ func humanFilters(orgID string) []expect { true, ), ), + expectFilterOrganizationSettings("org1", false, false), expectFilter( org.NewPasswordComplexityPolicyAddedEvent( context.Background(), @@ -519,6 +520,7 @@ func machineFilters(orgID string, pat bool) []expect { true, ), ), + expectFilterOrganizationSettings("org1", false, false), } if pat { filters = append(filters, @@ -562,6 +564,7 @@ func loginClientFilters(orgID string, pat bool) []expect { true, ), ), + expectFilterOrganizationSettings("org1", false, false), } if pat { filters = append(filters, diff --git a/internal/command/org.go b/internal/command/org.go index 215fe0b5cc..6505c8eef5 100644 --- a/internal/command/org.go +++ b/internal/command/org.go @@ -522,10 +522,16 @@ func (c *Commands) prepareRemoveOrg(a *org.Aggregate) preparation.Validation { return nil, zerrors.ThrowNotFound(nil, "COMMA-aps2n", "Errors.Org.NotFound") } - domainPolicy, err := c.domainPolicyWriteModel(ctx, a.ID) + domainPolicy, err := domainPolicyWriteModel(ctx, filter, a.ID) if err != nil { return nil, err } + + organizationScopedUsername, err := checkOrganizationScopedUsernames(ctx, filter, a.ID, nil) + if err != nil { + return nil, err + } + usernames, err := OrgUsers(ctx, filter, a.ID) if err != nil { return nil, err @@ -542,7 +548,7 @@ func (c *Commands) prepareRemoveOrg(a *org.Aggregate) preparation.Validation { if err != nil { return nil, err } - return []eventstore.Command{org.NewOrgRemovedEvent(ctx, &a.Aggregate, writeModel.Name, usernames, domainPolicy.UserLoginMustBeDomain, domains, links, entityIds)}, nil + return []eventstore.Command{org.NewOrgRemovedEvent(ctx, &a.Aggregate, writeModel.Name, usernames, domainPolicy.UserLoginMustBeDomain || organizationScopedUsername, domains, links, entityIds)}, nil }, nil } } diff --git a/internal/command/org_domain_test.go b/internal/command/org_domain_test.go index df79710955..6aaee20c2a 100644 --- a/internal/command/org_domain_test.go +++ b/internal/command/org_domain_test.go @@ -1050,6 +1050,7 @@ func TestCommandSide_ValidateOrgDomain(t *testing.T) { org.NewDomainPolicyAddedEvent(context.Background(), &org.NewAggregate("org2").Aggregate, false, false, false))), + expectFilterOrganizationSettings("org2", false, false), expectPush( org.NewDomainVerifiedEvent(context.Background(), &org.NewAggregate("org1").Aggregate, @@ -1084,6 +1085,93 @@ func TestCommandSide_ValidateOrgDomain(t *testing.T) { }, }, }, + { + name: "domain verification, claimed users, orgScopedUsername, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + org.NewOrgAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + "name", + ), + ), + eventFromEventPusher( + org.NewDomainAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + "domain.ch", + ), + ), + eventFromEventPusher( + org.NewDomainVerificationAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + "domain.ch", + domain.OrgDomainValidationTypeDNS, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("a"), + }, + ), + ), + ), + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org2").Aggregate, + "username@domain.ch", + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email", + true, + ), + ), + ), + expectFilter( + eventFromEventPusher( + org.NewDomainPolicyAddedEvent(context.Background(), + &org.NewAggregate("org2").Aggregate, + false, false, false))), + expectFilterOrganizationSettings("org2", true, true), + expectPush( + org.NewDomainVerifiedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + "domain.ch", + ), + user.NewDomainClaimedEvent(http.WithRequestedHost(context.Background(), "zitadel.ch"), + &user.NewAggregate("user1", "org2").Aggregate, + "tempid@temporary.zitadel.ch", + "username@domain.ch", + true, + ), + ), + ), + alg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + domainValidationFunc: validDomainVerification, + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "tempid"), + }, + args: args{ + ctx: context.Background(), + domain: &domain.OrgDomain{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "org1", + }, + Domain: "domain.ch", + ValidationType: domain.OrgDomainValidationTypeDNS, + }, + claimedUserIDs: []string{"user1"}, + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/internal/command/org_policy_domain.go b/internal/command/org_policy_domain.go index c9a4fd547c..3729031629 100644 --- a/internal/command/org_policy_domain.go +++ b/internal/command/org_policy_domain.go @@ -124,13 +124,23 @@ func prepareAddOrgDomainPolicy( if instancePolicy.UserLoginMustBeDomain == userLoginMustBeDomain { return cmds, nil } + + organizationScopedUsernames, err := checkOrganizationScopedUsernames(ctx, filter, a.ID, nil) + if err != nil { + return nil, err + } + // the UserLoginMustBeDomain setting will be different from the instance // therefore get all usernames and the current primary domain usersWriteModel, err := domainPolicyUsernames(ctx, filter, a.ID) if err != nil { return nil, err } - return append(cmds, usersWriteModel.NewUsernameChangedEvents(ctx, userLoginMustBeDomain)...), nil + return append(cmds, usersWriteModel.NewUsernameChangedEvents(ctx, + userLoginMustBeDomain, + organizationScopedUsernames, + instancePolicy.UserLoginMustBeDomain, + )...), nil }, nil } } @@ -163,13 +173,22 @@ func prepareChangeOrgDomainPolicy( if !usernameChange { return cmds, err } + + organizationScopedUsernames, err := checkOrganizationScopedUsernames(ctx, filter, a.ID, nil) + if err != nil { + return nil, err + } // get all usernames and the primary domain usersWriteModel, err := domainPolicyUsernames(ctx, filter, a.ID) if err != nil { return nil, err } // to compute the username changed events - return append(cmds, usersWriteModel.NewUsernameChangedEvents(ctx, userLoginMustBeDomain)...), nil + return append(cmds, usersWriteModel.NewUsernameChangedEvents(ctx, + userLoginMustBeDomain, + organizationScopedUsernames, + writeModel.UserLoginMustBeDomain, + )...), nil }, nil } } @@ -190,13 +209,20 @@ func prepareRemoveOrgDomainPolicy( if err != nil { return nil, err } + policyChange := org.NewDomainPolicyRemovedEvent(ctx, &a.Aggregate) cmds := []eventstore.Command{ - org.NewDomainPolicyRemovedEvent(ctx, &a.Aggregate), + policyChange, } + + organizationScopedUsernames, err := checkOrganizationScopedUsernames(ctx, filter, a.ID, nil) + if err != nil { + return nil, err + } + // regardless if the UserLoginMustBeDomain setting is true or false, // if it will be the same value as currently on the instance, // then there no further changes are needed - if instancePolicy.UserLoginMustBeDomain == writeModel.UserLoginMustBeDomain { + if writeModel.UserLoginMustBeDomain == instancePolicy.UserLoginMustBeDomain { return cmds, nil } // get all usernames and the primary domain @@ -205,7 +231,11 @@ func prepareRemoveOrgDomainPolicy( return nil, err } // to compute the username changed events - return append(cmds, usersWriteModel.NewUsernameChangedEvents(ctx, instancePolicy.UserLoginMustBeDomain)...), nil + return append(cmds, usersWriteModel.NewUsernameChangedEvents(ctx, + instancePolicy.UserLoginMustBeDomain, + organizationScopedUsernames, + writeModel.UserLoginMustBeDomain, + )...), nil }, nil } } diff --git a/internal/command/org_policy_domain_test.go b/internal/command/org_policy_domain_test.go index 24020d8026..a48f7eb794 100644 --- a/internal/command/org_policy_domain_test.go +++ b/internal/command/org_policy_domain_test.go @@ -18,7 +18,7 @@ import ( func TestCommandSide_AddDomainPolicy(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(t *testing.T) *eventstore.Eventstore } type args struct { ctx context.Context @@ -40,9 +40,7 @@ func TestCommandSide_AddDomainPolicy(t *testing.T) { { name: "org id missing, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), }, args: args{ ctx: context.Background(), @@ -57,8 +55,7 @@ func TestCommandSide_AddDomainPolicy(t *testing.T) { { name: "policy already existing, already exists error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( org.NewDomainPolicyAddedEvent(context.Background(), @@ -85,8 +82,7 @@ func TestCommandSide_AddDomainPolicy(t *testing.T) { { name: "add policy, no userLoginMustBeDomain change, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), expectFilter( eventFromEventPusher( @@ -124,8 +120,7 @@ func TestCommandSide_AddDomainPolicy(t *testing.T) { { name: "add policy, userLoginMustBeDomain changed, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), expectFilter( eventFromEventPusher( @@ -137,6 +132,7 @@ func TestCommandSide_AddDomainPolicy(t *testing.T) { ), ), ), + expectFilterOrganizationSettings("org1", false, false), expectFilter( eventFromEventPusher( org.NewDomainVerifiedEvent( @@ -170,7 +166,7 @@ func TestCommandSide_AddDomainPolicy(t *testing.T) { eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), &user.NewAggregate("user1", "org1").Aggregate, - "user1@org.com", + "user1", "firstname", "lastname", "nickname", @@ -205,17 +201,19 @@ func TestCommandSide_AddDomainPolicy(t *testing.T) { ), user.NewUsernameChangedEvent(context.Background(), &user.NewAggregate("user1", "org1").Aggregate, - "user1@org.com", + "user1", "user1", true, - user.UsernameChangedEventWithPolicyChange(), + false, + user.UsernameChangedEventWithPolicyChange(false), ), user.NewUsernameChangedEvent(context.Background(), &user.NewAggregate("user2", "org1").Aggregate, "user@test.com", "user@test.com", true, - user.UsernameChangedEventWithPolicyChange(), + false, + user.UsernameChangedEventWithPolicyChange(false), ), ), ), @@ -233,11 +231,239 @@ func TestCommandSide_AddDomainPolicy(t *testing.T) { }, }, }, + { + name: "add policy, userLoginMustBeDomain changed, org scoped usernames, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + expectFilter( + eventFromEventPusher( + instance.NewDomainPolicyAddedEvent(context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + false, + false, + false, + ), + ), + ), + expectFilterOrganizationSettings("org1", true, true), + expectFilter( + eventFromEventPusher( + org.NewDomainVerifiedEvent( + context.Background(), + &org.NewAggregate("org1").Aggregate, + "org.com", + ), + ), + eventFromEventPusher( + org.NewDomainVerifiedEvent( + context.Background(), + &org.NewAggregate("org1").Aggregate, + "test.com", + ), + ), + eventFromEventPusher( + org.NewDomainRemovedEvent( + context.Background(), + &org.NewAggregate("org1").Aggregate, + "test.com", + true, + ), + ), + eventFromEventPusher( + org.NewDomainPrimarySetEvent( + context.Background(), + &org.NewAggregate("org1").Aggregate, + "org.com", + ), + ), + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "user1", + "firstname", + "lastname", + "nickname", + "displayname", + language.English, + domain.GenderUnspecified, + "user1@org.com", + false, + ), + ), + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user2", "org1").Aggregate, + "user@test.com", + "firstname", + "lastname", + "nickname", + "displayname", + language.English, + domain.GenderUnspecified, + "user@test.com", + false, + ), + ), + ), + expectPush( + org.NewDomainPolicyAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + true, + true, + true, + ), + user.NewUsernameChangedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "user1", + "user1", + true, + true, + user.UsernameChangedEventWithPolicyChange(false), + ), + user.NewUsernameChangedEvent(context.Background(), + &user.NewAggregate("user2", "org1").Aggregate, + "user@test.com", + "user@test.com", + true, + true, + user.UsernameChangedEventWithPolicyChange(false), + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + userLoginMustBeDomain: true, + validateOrgDomains: true, + smtpSenderAddressMatchesInstanceDomain: true, + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + name: "add policy, userLoginMustBeDomain removed, org scoped usernames, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + expectFilter( + eventFromEventPusher( + instance.NewDomainPolicyAddedEvent(context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + true, + false, + false, + ), + ), + ), + expectFilterOrganizationSettings("org1", true, true), + expectFilter( + eventFromEventPusher( + org.NewDomainVerifiedEvent( + context.Background(), + &org.NewAggregate("org1").Aggregate, + "org.com", + ), + ), + eventFromEventPusher( + org.NewDomainVerifiedEvent( + context.Background(), + &org.NewAggregate("org1").Aggregate, + "test.com", + ), + ), + eventFromEventPusher( + org.NewDomainRemovedEvent( + context.Background(), + &org.NewAggregate("org1").Aggregate, + "test.com", + true, + ), + ), + eventFromEventPusher( + org.NewDomainPrimarySetEvent( + context.Background(), + &org.NewAggregate("org1").Aggregate, + "org.com", + ), + ), + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "user1", + "firstname", + "lastname", + "nickname", + "displayname", + language.English, + domain.GenderUnspecified, + "user1@org.com", + true, + ), + ), + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user2", "org1").Aggregate, + "user@test.com", + "firstname", + "lastname", + "nickname", + "displayname", + language.English, + domain.GenderUnspecified, + "user@test.com", + true, + ), + ), + ), + expectPush( + org.NewDomainPolicyAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + false, + true, + true, + ), + user.NewUsernameChangedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "user1", + "user1@org.com", + false, + true, + user.UsernameChangedEventWithPolicyChange(true), + ), + user.NewUsernameChangedEvent(context.Background(), + &user.NewAggregate("user2", "org1").Aggregate, + "user@test.com", + "user@test.com@org.com", + false, + true, + user.UsernameChangedEventWithPolicyChange(true), + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + userLoginMustBeDomain: false, + validateOrgDomains: true, + smtpSenderAddressMatchesInstanceDomain: true, + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), } got, err := r.AddOrgDomainPolicy(tt.args.ctx, tt.args.orgID, tt.args.userLoginMustBeDomain, tt.args.validateOrgDomains, tt.args.smtpSenderAddressMatchesInstanceDomain) if tt.res.err == nil { @@ -255,7 +481,7 @@ func TestCommandSide_AddDomainPolicy(t *testing.T) { func TestCommandSide_ChangeDomainPolicy(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(t *testing.T) *eventstore.Eventstore } type args struct { ctx context.Context @@ -277,9 +503,7 @@ func TestCommandSide_ChangeDomainPolicy(t *testing.T) { { name: "org id missing, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), }, args: args{ ctx: context.Background(), @@ -294,8 +518,7 @@ func TestCommandSide_ChangeDomainPolicy(t *testing.T) { { name: "policy not existing, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), }, @@ -313,8 +536,7 @@ func TestCommandSide_ChangeDomainPolicy(t *testing.T) { { name: "no changes, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( org.NewDomainPolicyAddedEvent(context.Background(), @@ -341,8 +563,7 @@ func TestCommandSide_ChangeDomainPolicy(t *testing.T) { { name: "change, no userLoginMustBeDomain change, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( org.NewDomainPolicyAddedEvent(context.Background(), @@ -377,8 +598,7 @@ func TestCommandSide_ChangeDomainPolicy(t *testing.T) { { name: "change, userLoginMustBeDomain changed, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( org.NewDomainPolicyAddedEvent(context.Background(), @@ -389,6 +609,7 @@ func TestCommandSide_ChangeDomainPolicy(t *testing.T) { ), ), ), + expectFilterOrganizationSettings("org1", false, false), expectFilter( eventFromEventPusher( org.NewDomainPrimarySetEvent( @@ -429,7 +650,156 @@ func TestCommandSide_ChangeDomainPolicy(t *testing.T) { "user1", "user1@org.com", false, - user.UsernameChangedEventWithPolicyChange(), + false, + user.UsernameChangedEventWithPolicyChange(true), + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + userLoginMustBeDomain: false, + validateOrgDomains: false, + smtpSenderAddressMatchesInstanceDomain: false, + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + name: "change, userLoginMustBeDomain changed, org scoped usernames, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + org.NewDomainPolicyAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + false, + true, + true, + ), + ), + ), + expectFilterOrganizationSettings("org1", true, true), + expectFilter( + eventFromEventPusher( + org.NewDomainPrimarySetEvent( + context.Background(), + &org.NewAggregate("org1").Aggregate, + "org.com", + ), + ), + eventFromEventPusher( + org.NewDomainPrimarySetEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + "org.com", + ), + ), + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "user1", + "firstname", + "lastname", + "nickname", + "displayname", + language.English, + domain.GenderUnspecified, + "user1@org.com", + false, + ), + ), + ), + expectPush( + newDomainPolicyChangedEvent(context.Background(), "org1", + policy.ChangeUserLoginMustBeDomain(true), + policy.ChangeValidateOrgDomains(false), + policy.ChangeSMTPSenderAddressMatchesInstanceDomain(false), + ), + user.NewUsernameChangedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "user1", + "user1", + true, + true, + user.UsernameChangedEventWithPolicyChange(false), + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + userLoginMustBeDomain: true, + validateOrgDomains: false, + smtpSenderAddressMatchesInstanceDomain: false, + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + name: "change, userLoginMustBeDomain removed, org scoped usernames, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + org.NewDomainPolicyAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + true, + true, + true, + ), + ), + ), + expectFilterOrganizationSettings("org1", true, true), + expectFilter( + eventFromEventPusher( + org.NewDomainPrimarySetEvent( + context.Background(), + &org.NewAggregate("org1").Aggregate, + "org.com", + ), + ), + eventFromEventPusher( + org.NewDomainPrimarySetEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + "org.com", + ), + ), + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "user1", + "firstname", + "lastname", + "nickname", + "displayname", + language.English, + domain.GenderUnspecified, + "user1@org.com", + true, + ), + ), + ), + expectPush( + newDomainPolicyChangedEvent(context.Background(), "org1", + policy.ChangeUserLoginMustBeDomain(false), + policy.ChangeValidateOrgDomains(false), + policy.ChangeSMTPSenderAddressMatchesInstanceDomain(false), + ), + user.NewUsernameChangedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "user1", + "user1@org.com", + false, + true, + user.UsernameChangedEventWithPolicyChange(true), ), ), ), @@ -451,7 +821,7 @@ func TestCommandSide_ChangeDomainPolicy(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), } got, err := r.ChangeOrgDomainPolicy(tt.args.ctx, tt.args.orgID, tt.args.userLoginMustBeDomain, tt.args.validateOrgDomains, tt.args.smtpSenderAddressMatchesInstanceDomain) if tt.res.err == nil { @@ -469,7 +839,7 @@ func TestCommandSide_ChangeDomainPolicy(t *testing.T) { func TestCommandSide_RemoveDomainPolicy(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(t *testing.T) *eventstore.Eventstore } type args struct { ctx context.Context @@ -488,9 +858,7 @@ func TestCommandSide_RemoveDomainPolicy(t *testing.T) { { name: "org id missing, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), }, args: args{ ctx: context.Background(), @@ -502,8 +870,7 @@ func TestCommandSide_RemoveDomainPolicy(t *testing.T) { { name: "policy not existing, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), }, @@ -518,8 +885,7 @@ func TestCommandSide_RemoveDomainPolicy(t *testing.T) { { name: "remove, no userLoginMustBeDomain change, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( org.NewDomainPolicyAddedEvent(context.Background(), @@ -540,6 +906,7 @@ func TestCommandSide_RemoveDomainPolicy(t *testing.T) { ), ), ), + expectFilterOrganizationSettings("org1", false, false), expectPush( org.NewDomainPolicyRemovedEvent(context.Background(), &org.NewAggregate("org1").Aggregate), @@ -559,8 +926,7 @@ func TestCommandSide_RemoveDomainPolicy(t *testing.T) { { name: "remove, userLoginMustBeDomain changed, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( org.NewDomainPolicyAddedEvent(context.Background(), @@ -581,6 +947,7 @@ func TestCommandSide_RemoveDomainPolicy(t *testing.T) { ), ), ), + expectFilterOrganizationSettings("org1", false, false), expectFilter( eventFromEventPusher( org.NewDomainPrimarySetEvent( @@ -619,7 +986,166 @@ func TestCommandSide_RemoveDomainPolicy(t *testing.T) { "user1", "user1@org.com", false, - user.UsernameChangedEventWithPolicyChange(), + false, + user.UsernameChangedEventWithPolicyChange(true), + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + name: "remove, userLoginMustBeDomain removed, org scoped usernames, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + org.NewDomainPolicyAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + true, + true, + true, + ), + ), + ), + expectFilter( + eventFromEventPusher( + instance.NewDomainPolicyAddedEvent(context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + false, + true, + true, + ), + ), + ), + expectFilterOrganizationSettings("org1", true, true), + expectFilter( + eventFromEventPusher( + org.NewDomainPrimarySetEvent( + context.Background(), + &org.NewAggregate("org1").Aggregate, + "org.com", + ), + ), + eventFromEventPusher( + org.NewDomainPrimarySetEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + "org.com", + ), + ), + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "user1", + "firstname", + "lastname", + "nickname", + "displayname", + language.English, + domain.GenderUnspecified, + "user1@org.com", + false, + ), + ), + ), + expectPush( + org.NewDomainPolicyRemovedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + ), + user.NewUsernameChangedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "user1", + "user1@org.com", + false, + true, + user.UsernameChangedEventWithPolicyChange(true), + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + name: "remove, userLoginMustBeDomain changed, org scoped usernames, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + org.NewDomainPolicyAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + false, + true, + true, + ), + ), + ), + expectFilter( + eventFromEventPusher( + instance.NewDomainPolicyAddedEvent(context.Background(), + &instance.NewAggregate("instanceID").Aggregate, + true, + true, + true, + ), + ), + ), + expectFilterOrganizationSettings("org1", true, true), + expectFilter( + eventFromEventPusher( + org.NewDomainPrimarySetEvent( + context.Background(), + &org.NewAggregate("org1").Aggregate, + "org.com", + ), + ), + eventFromEventPusher( + org.NewDomainPrimarySetEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + "org.com", + ), + ), + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "user1", + "firstname", + "lastname", + "nickname", + "displayname", + language.English, + domain.GenderUnspecified, + "user1@org.com", + true, + ), + ), + ), + expectPush( + org.NewDomainPolicyRemovedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + ), + user.NewUsernameChangedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "user1", + "user1", + true, + true, + user.UsernameChangedEventWithPolicyChange(false), ), ), ), @@ -638,7 +1164,7 @@ func TestCommandSide_RemoveDomainPolicy(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), } got, err := r.RemoveOrgDomainPolicy(tt.args.ctx, tt.args.orgID) if tt.res.err == nil { diff --git a/internal/command/org_test.go b/internal/command/org_test.go index c07d5f7678..a2c2713874 100644 --- a/internal/command/org_test.go +++ b/internal/command/org_test.go @@ -1101,6 +1101,7 @@ func TestCommandSide_RemoveOrg(t *testing.T) { ), ), ), + expectFilterOrganizationSettings("org1", false, false), expectFilter(), expectFilter(), expectFilter(), @@ -1143,6 +1144,7 @@ func TestCommandSide_RemoveOrg(t *testing.T) { ), ), ), + expectFilterOrganizationSettings("org1", false, false), expectFilter(), expectFilter(), expectFilter(), @@ -1183,6 +1185,7 @@ func TestCommandSide_RemoveOrg(t *testing.T) { ), ), ), + expectFilterOrganizationSettings("org1", false, false), expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -1397,6 +1400,7 @@ func TestCommandSide_SetUpOrg(t *testing.T) { ), ), ), + expectFilterOrganizationSettings("org1", false, false), expectFilter(), // org member check expectFilter( eventFromEventPusher( @@ -1706,6 +1710,7 @@ func TestCommandSide_SetUpOrg(t *testing.T) { ), ), ), + expectFilterOrganizationSettings("org1", false, false), expectFilter(), expectFilter(), expectFilter(), // org member check diff --git a/internal/command/organization_settings.go b/internal/command/organization_settings.go new file mode 100644 index 0000000000..2459fd60d8 --- /dev/null +++ b/internal/command/organization_settings.go @@ -0,0 +1,140 @@ +package command + +import ( + "context" + + "github.com/zitadel/zitadel/internal/command/preparation" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/telemetry/tracing" + "github.com/zitadel/zitadel/internal/zerrors" +) + +type SetOrganizationSettings struct { + OrganizationID string + + OrganizationScopedUsernames *bool +} + +func (e *SetOrganizationSettings) IsValid() error { + if e.OrganizationID == "" { + return zerrors.ThrowInvalidArgument(nil, "COMMAND-zI4z7cLLRJ", "Errors.Org.Settings.Invalid") + } + return nil +} + +func (c *Commands) SetOrganizationSettings(ctx context.Context, set *SetOrganizationSettings) (_ *domain.ObjectDetails, err error) { + if err := set.IsValid(); err != nil { + return nil, err + } + wm, err := c.getOrganizationSettingsWriteModelByID(ctx, set.OrganizationID) + if err != nil { + return nil, err + } + if !wm.OrganizationState.Exists() { + return nil, zerrors.ThrowNotFound(nil, "COMMAND-oDzwP5kmdP", "Errors.NotFound") + } + + domainPolicy, err := c.domainPolicyWriteModel(ctx, wm.AggregateID) + if err != nil { + return nil, err + } + + events, err := wm.NewSet(ctx, + set.OrganizationScopedUsernames, + domainPolicy.UserLoginMustBeDomain, + c.getOrganizationScopedUsernames, + ) + if err != nil { + return nil, err + } + + return c.pushAppendAndReduceDetails(ctx, wm, events...) +} + +func (c *Commands) DeleteOrganizationSettings(ctx context.Context, id string) (*domain.ObjectDetails, error) { + if id == "" { + return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-eU5hkMy3Pf", "Errors.IDMissing") + } + wm, err := c.getOrganizationSettingsWriteModelByID(ctx, id) + if err != nil { + return nil, err + } + if !wm.State.Exists() { + return writeModelToObjectDetails(wm.GetWriteModel()), nil + } + + domainPolicy, err := c.domainPolicyWriteModel(ctx, wm.AggregateID) + if err != nil { + return nil, err + } + + events, err := wm.NewRemoved(ctx, + domainPolicy.UserLoginMustBeDomain, + c.getOrganizationScopedUsernames, + ) + if err != nil { + return nil, err + } + + return c.pushAppendAndReduceDetails(ctx, wm, events...) +} + +func checkOrganizationScopedUsernames(ctx context.Context, filter preparation.FilterToQueryReducer, id string, checkPermission domain.PermissionCheck) (_ bool, err error) { + wm := NewOrganizationSettingsWriteModel(id, checkPermission) + events, err := filter(ctx, wm.Query()) + if err != nil { + return false, err + } + if len(events) == 0 { + return false, nil + } + wm.AppendEvents(events...) + err = wm.Reduce() + if err != nil { + return false, err + } + + return wm.State.Exists() && wm.OrganizationScopedUsernames, nil +} + +func (c *Commands) getOrganizationSettingsWriteModelByID(ctx context.Context, id string) (*OrganizationSettingsWriteModel, error) { + wm := NewOrganizationSettingsWriteModel(id, c.checkPermission) + err := c.eventstore.FilterToQueryReducer(ctx, wm) + if err != nil { + return nil, err + } + return wm, nil +} + +func (c *Commands) checkOrganizationScopedUsernames(ctx context.Context, orgID string) (_ bool, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + wm, err := c.getOrganizationSettingsWriteModelByID(ctx, orgID) + if err != nil { + return false, err + } + + return wm.State.Exists() && wm.OrganizationScopedUsernames, nil +} + +func (c *Commands) getOrganizationScopedUsernamesWriteModelByID(ctx context.Context, id string) (*OrganizationScopedUsernamesWriteModel, error) { + wm := NewOrganizationScopedUsernamesWriteModel(id) + err := c.eventstore.FilterToQueryReducer(ctx, wm) + if err != nil { + return nil, err + } + return wm, nil +} + +func (c *Commands) getOrganizationScopedUsernames(ctx context.Context, id string) ([]string, error) { + wm, err := c.getOrganizationScopedUsernamesWriteModelByID(ctx, id) + if err != nil { + return nil, err + } + usernames := make([]string, len(wm.Users)) + for i, user := range wm.Users { + usernames[i] = user.username + } + return usernames, nil +} diff --git a/internal/command/organization_settings_model.go b/internal/command/organization_settings_model.go new file mode 100644 index 0000000000..890cf671e5 --- /dev/null +++ b/internal/command/organization_settings_model.go @@ -0,0 +1,245 @@ +package command + +import ( + "context" + + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/org" + settings "github.com/zitadel/zitadel/internal/repository/organization_settings" + "github.com/zitadel/zitadel/internal/repository/user" + "github.com/zitadel/zitadel/internal/zerrors" +) + +type OrganizationSettingsWriteModel struct { + eventstore.WriteModel + + OrganizationScopedUsernames bool + + OrganizationState domain.OrgState + + State domain.OrganizationSettingsState + checkPermission domain.PermissionCheck +} + +func (wm *OrganizationSettingsWriteModel) GetWriteModel() *eventstore.WriteModel { + return &wm.WriteModel +} + +func (wm *OrganizationSettingsWriteModel) checkPermissionWrite( + ctx context.Context, + resourceOwner string, + aggregateID string, +) error { + if wm.checkPermission == nil { + return zerrors.ThrowPermissionDenied(nil, "COMMAND-8Dttuyj0B4", "Permission check not defined") + } + return wm.checkPermission(ctx, domain.PermissionIAMPolicyWrite, resourceOwner, aggregateID) +} + +func (wm *OrganizationSettingsWriteModel) checkPermissionDelete( + ctx context.Context, + resourceOwner string, + aggregateID string, +) error { + if wm.checkPermission == nil { + return zerrors.ThrowPermissionDenied(nil, "COMMAND-6R54f4vWqv", "Permission check not defined") + } + return wm.checkPermission(ctx, domain.PermissionIAMPolicyDelete, resourceOwner, aggregateID) +} + +func NewOrganizationSettingsWriteModel(id string, checkPermission domain.PermissionCheck) *OrganizationSettingsWriteModel { + return &OrganizationSettingsWriteModel{ + WriteModel: eventstore.WriteModel{ + AggregateID: id, + ResourceOwner: id, + }, + checkPermission: checkPermission, + } +} + +func (wm *OrganizationSettingsWriteModel) Reduce() error { + for _, event := range wm.Events { + switch e := event.(type) { + case *settings.OrganizationSettingsSetEvent: + wm.OrganizationScopedUsernames = e.OrganizationScopedUsernames + wm.State = domain.OrganizationSettingsStateActive + case *settings.OrganizationSettingsRemovedEvent: + wm.OrganizationScopedUsernames = false + wm.State = domain.OrganizationSettingsStateRemoved + case *org.OrgAddedEvent: + wm.OrganizationState = domain.OrgStateActive + wm.OrganizationScopedUsernames = false + case *org.OrgRemovedEvent: + wm.OrganizationState = domain.OrgStateRemoved + wm.OrganizationScopedUsernames = false + } + } + return wm.WriteModel.Reduce() +} + +func (wm *OrganizationSettingsWriteModel) Query() *eventstore.SearchQueryBuilder { + return eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). + ResourceOwner(wm.ResourceOwner). + AddQuery(). + AggregateTypes(settings.AggregateType). + AggregateIDs(wm.AggregateID). + EventTypes(settings.OrganizationSettingsSetEventType, + settings.OrganizationSettingsRemovedEventType). + Or(). + AggregateTypes(org.AggregateType). + AggregateIDs(wm.AggregateID). + EventTypes(org.OrgAddedEventType, + org.OrgRemovedEventType). + Builder() +} + +func (wm *OrganizationSettingsWriteModel) NewSet( + ctx context.Context, + organizationScopedUsernames *bool, + userLoginMustBeDomain bool, + usernamesF func(ctx context.Context, orgID string) ([]string, error), +) (_ []eventstore.Command, err error) { + if err := wm.checkPermissionWrite(ctx, wm.ResourceOwner, wm.AggregateID); err != nil { + return nil, err + } + // no changes + if organizationScopedUsernames == nil || *organizationScopedUsernames == wm.OrganizationScopedUsernames { + return nil, nil + } + + var usernames []string + if (wm.OrganizationScopedUsernames || userLoginMustBeDomain) != (*organizationScopedUsernames || userLoginMustBeDomain) { + usernames, err = usernamesF(ctx, wm.AggregateID) + if err != nil { + return nil, err + } + } + events := []eventstore.Command{ + settings.NewOrganizationSettingsAddedEvent(ctx, + SettingsAggregateFromWriteModel(&wm.WriteModel), + usernames, + *organizationScopedUsernames || userLoginMustBeDomain, + wm.OrganizationScopedUsernames || userLoginMustBeDomain, + ), + } + return events, nil +} + +func (wm *OrganizationSettingsWriteModel) NewRemoved( + ctx context.Context, + userLoginMustBeDomain bool, + usernamesF func(ctx context.Context, orgID string) ([]string, error), +) (_ []eventstore.Command, err error) { + if err := wm.checkPermissionDelete(ctx, wm.ResourceOwner, wm.AggregateID); err != nil { + return nil, err + } + + var usernames []string + if userLoginMustBeDomain != wm.OrganizationScopedUsernames { + usernames, err = usernamesF(ctx, wm.AggregateID) + if err != nil { + return nil, err + } + } + events := []eventstore.Command{ + settings.NewOrganizationSettingsRemovedEvent(ctx, + SettingsAggregateFromWriteModel(&wm.WriteModel), + usernames, + userLoginMustBeDomain, + wm.OrganizationScopedUsernames || userLoginMustBeDomain, + ), + } + return events, nil +} + +func SettingsAggregateFromWriteModel(wm *eventstore.WriteModel) *eventstore.Aggregate { + return &eventstore.Aggregate{ + ID: wm.AggregateID, + Type: settings.AggregateType, + ResourceOwner: wm.ResourceOwner, + InstanceID: wm.InstanceID, + Version: settings.AggregateVersion, + } +} + +type OrganizationScopedUsernamesWriteModel struct { + eventstore.WriteModel + + Users []*organizationScopedUser +} + +type organizationScopedUser struct { + id string + username string +} + +func NewOrganizationScopedUsernamesWriteModel(orgID string) *OrganizationScopedUsernamesWriteModel { + return &OrganizationScopedUsernamesWriteModel{ + WriteModel: eventstore.WriteModel{ + ResourceOwner: orgID, + }, + Users: make([]*organizationScopedUser, 0), + } +} + +func (wm *OrganizationScopedUsernamesWriteModel) AppendEvents(events ...eventstore.Event) { + wm.WriteModel.AppendEvents(events...) +} + +func (wm *OrganizationScopedUsernamesWriteModel) Reduce() error { + for _, event := range wm.Events { + switch e := event.(type) { + case *user.HumanAddedEvent: + wm.Users = append(wm.Users, &organizationScopedUser{id: e.Aggregate().ID, username: e.UserName}) + case *user.HumanRegisteredEvent: + wm.Users = append(wm.Users, &organizationScopedUser{id: e.Aggregate().ID, username: e.UserName}) + case *user.MachineAddedEvent: + wm.Users = append(wm.Users, &organizationScopedUser{id: e.Aggregate().ID, username: e.UserName}) + case *user.UsernameChangedEvent: + for _, user := range wm.Users { + if user.id == e.Aggregate().ID { + user.username = e.UserName + break + } + } + case *user.DomainClaimedEvent: + for _, user := range wm.Users { + if user.id == e.Aggregate().ID { + user.username = e.UserName + break + } + } + case *user.UserRemovedEvent: + wm.removeUser(e.Aggregate().ID) + } + } + return wm.WriteModel.Reduce() +} + +func (wm *OrganizationScopedUsernamesWriteModel) removeUser(userID string) { + for i, user := range wm.Users { + if user.id == userID { + wm.Users[i] = wm.Users[len(wm.Users)-1] + wm.Users[len(wm.Users)-1] = nil + wm.Users = wm.Users[:len(wm.Users)-1] + return + } + } +} + +func (wm *OrganizationScopedUsernamesWriteModel) Query() *eventstore.SearchQueryBuilder { + return eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). + ResourceOwner(wm.ResourceOwner). + AddQuery(). + AggregateTypes(user.AggregateType). + EventTypes( + user.HumanAddedType, + user.HumanRegisteredType, + user.MachineAddedEventType, + user.UserUserNameChangedType, + user.UserDomainClaimedType, + user.UserRemovedType, + ). + Builder() +} diff --git a/internal/command/organization_settings_test.go b/internal/command/organization_settings_test.go new file mode 100644 index 0000000000..f6317fd35f --- /dev/null +++ b/internal/command/organization_settings_test.go @@ -0,0 +1,542 @@ +package command + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "golang.org/x/text/language" + + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/org" + settings "github.com/zitadel/zitadel/internal/repository/organization_settings" + "github.com/zitadel/zitadel/internal/repository/user" + "github.com/zitadel/zitadel/internal/zerrors" +) + +func TestCommandSide_SetSettingsOrganization(t *testing.T) { + type fields struct { + eventstore func(t *testing.T) *eventstore.Eventstore + checkPermission domain.PermissionCheck + } + type args struct { + ctx context.Context + settings *SetOrganizationSettings + } + type res struct { + want *domain.ObjectDetails + err func(error) bool + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + name: "org id missing, invalid argument error", + fields: fields{ + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + settings: &SetOrganizationSettings{ + OrganizationID: "", + OrganizationScopedUsernames: boolPtr(true), + }, + }, + res: res{ + err: zerrors.IsErrorInvalidArgument, + }, + }, + { + name: "org not found, not found error", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + settings: &SetOrganizationSettings{ + OrganizationID: "org1", + OrganizationScopedUsernames: boolPtr(true), + }, + }, + res: res{ + err: zerrors.IsNotFound, + }, + }, + { + name: "settings already existing, no changes", + fields: fields{ + eventstore: expectEventstore( + expectFilterPreOrganizationSettings("org1", true, true, true), + expectFilterOrgDomainPolicy(false), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + settings: &SetOrganizationSettings{ + OrganizationID: "org1", + OrganizationScopedUsernames: boolPtr(true), + }, + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + ID: "org1", + }, + }, + }, + { + name: "settings set, new", + fields: fields{ + eventstore: expectEventstore( + expectFilterPreOrganizationSettings("org1", true, false, false), + expectFilterOrgDomainPolicy(false), + expectFilterOrganizationScopedUsernames(false, "username1", "username2", "username3"), + expectPush( + settings.NewOrganizationSettingsAddedEvent(context.Background(), + &settings.NewAggregate("org1", "org1").Aggregate, + []string{"username1", "username2", "username3"}, + true, + false, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + settings: &SetOrganizationSettings{ + OrganizationID: "org1", + OrganizationScopedUsernames: boolPtr(true), + }, + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + ID: "org1", + }, + }, + }, + { + name: "settings set, no permission", + fields: fields{ + eventstore: expectEventstore( + expectFilterPreOrganizationSettings("org1", true, false, true), + expectFilterOrgDomainPolicy(false), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args: args{ + ctx: context.Background(), + settings: &SetOrganizationSettings{ + OrganizationID: "org1", + OrganizationScopedUsernames: boolPtr(true), + }, + }, + res: res{ + err: zerrors.IsPermissionDenied, + }, + }, + { + name: "settings set, changed", + fields: fields{ + eventstore: expectEventstore( + expectFilterPreOrganizationSettings("org1", true, true, false), + expectFilterOrgDomainPolicy(false), + expectFilterOrganizationScopedUsernames(false, "username1", "username2", "username3"), + expectPush( + settings.NewOrganizationSettingsAddedEvent(context.Background(), + &settings.NewAggregate("org1", "org1").Aggregate, + []string{"username1", "username2", "username3"}, + true, + false, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + settings: &SetOrganizationSettings{ + OrganizationID: "org1", + OrganizationScopedUsernames: boolPtr(true), + }, + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + ID: "org1", + }, + }, + }, + { + name: "settings not set, not existing", + fields: fields{ + eventstore: expectEventstore( + expectFilterPreOrganizationSettings("org1", true, false, false), + expectFilterOrgDomainPolicy(false), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + settings: &SetOrganizationSettings{ + OrganizationID: "org1", + OrganizationScopedUsernames: boolPtr(false), + }, + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + ID: "org1", + }, + }, + }, + { + name: "settings set, changed, usernameMustBeDomain set", + fields: fields{ + eventstore: expectEventstore( + expectFilterPreOrganizationSettings("org1", true, true, false), + expectFilterOrgDomainPolicy(true), + expectPush( + settings.NewOrganizationSettingsAddedEvent(context.Background(), + &settings.NewAggregate("org1", "org1").Aggregate, + []string{"username1", "username2", "username3"}, + true, + true, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + settings: &SetOrganizationSettings{ + OrganizationID: "org1", + OrganizationScopedUsernames: boolPtr(true), + }, + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + ID: "org1", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Commands{ + eventstore: tt.fields.eventstore(t), + checkPermission: tt.fields.checkPermission, + } + got, err := r.SetOrganizationSettings(tt.args.ctx, tt.args.settings) + if tt.res.err == nil { + assert.NoError(t, err) + } + if tt.res.err != nil && !tt.res.err(err) { + t.Errorf("got wrong err: %v ", err) + } + if tt.res.err == nil { + assert.Equal(t, tt.res.want, got) + } + }) + } +} + +func TestCommandSide_DeleteSettingsOrganization(t *testing.T) { + type fields struct { + eventstore func(t *testing.T) *eventstore.Eventstore + checkPermission domain.PermissionCheck + } + type args struct { + ctx context.Context + orgID string + } + type res struct { + want *domain.ObjectDetails + err func(error) bool + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + name: "org id missing, invalid argument error", + fields: fields{ + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + orgID: "", + }, + res: res{ + err: zerrors.IsErrorInvalidArgument, + }, + }, + { + name: "settings delete, no change", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + ID: "org1", + }, + }, + }, + { + name: "settings delete, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilterOrganizationSettings("org1", true, true), + expectFilterOrgDomainPolicy(false), + expectFilterOrganizationScopedUsernames(false, "username1", "username2", "username3"), + expectPush( + settings.NewOrganizationSettingsRemovedEvent(context.Background(), + &settings.NewAggregate("org1", "org1").Aggregate, + []string{"username1", "username2", "username3"}, + false, + true, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + ID: "org1", + }, + }, + }, + { + name: "settings delete, unset, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilterOrganizationSettings("org1", true, false), + expectFilterOrgDomainPolicy(false), + expectPush( + settings.NewOrganizationSettingsRemovedEvent(context.Background(), + &settings.NewAggregate("org1", "org1").Aggregate, + []string{"username1", "username2", "username3"}, + false, + false, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + ID: "org1", + }, + }, + }, + { + name: "settings delete, unset, usernameMustBeDomain set", + fields: fields{ + eventstore: expectEventstore( + expectFilterOrganizationSettings("org1", true, false), + expectFilterOrgDomainPolicy(true), + expectFilterOrganizationScopedUsernames(true, "username1", "username2", "username3"), + expectPush( + settings.NewOrganizationSettingsRemovedEvent(context.Background(), + &settings.NewAggregate("org1", "org1").Aggregate, + []string{"username1", "username2", "username3"}, + true, + true, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + ID: "org1", + }, + }, + }, + { + name: "settings delete, set, usernameMustBeDomain set", + fields: fields{ + eventstore: expectEventstore( + expectFilterOrganizationSettings("org1", true, true), + expectFilterOrgDomainPolicy(true), + expectPush( + settings.NewOrganizationSettingsRemovedEvent(context.Background(), + &settings.NewAggregate("org1", "org1").Aggregate, + []string{"username1", "username2", "username3"}, + true, + true, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + ID: "org1", + }, + }, + }, + { + name: "settings delete, no permission", + fields: fields{ + eventstore: expectEventstore( + expectFilterOrganizationSettings("org1", true, true), + expectFilterOrgDomainPolicy(true), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + }, + res: res{ + err: zerrors.IsPermissionDenied, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Commands{ + eventstore: tt.fields.eventstore(t), + checkPermission: tt.fields.checkPermission, + } + got, err := r.DeleteOrganizationSettings(tt.args.ctx, tt.args.orgID) + if tt.res.err == nil { + assert.NoError(t, err) + } + if tt.res.err != nil && !tt.res.err(err) { + t.Errorf("got wrong err: %v ", err) + } + if tt.res.err == nil { + assert.Equal(t, tt.res.want, got) + } + }) + } +} + +func expectFilterPreOrganizationSettings(orgID string, orgExisting, settingExisting, orgScopedUsernames bool) expect { + var events []eventstore.Event + events = append(events, + expectFilterPreOrganizationSettingsEvents(context.Background(), orgID, orgExisting)..., + ) + events = append(events, + expectFilterOrganizationSettingsEvents(context.Background(), orgID, settingExisting, orgScopedUsernames)..., + ) + return expectFilter( + events..., + ) +} + +func expectFilterPreOrganizationSettingsEvents(ctx context.Context, orgID string, orgExisting bool) []eventstore.Event { + var events []eventstore.Event + if orgExisting { + events = append(events, + eventFromEventPusher( + org.NewOrgAddedEvent(ctx, + &org.NewAggregate(orgID).Aggregate, + "org", + ), + ), + ) + } + return events +} + +func expectFilterOrganizationSettings(orgID string, settingExisting, orgScopedUsernames bool) expect { + return expectFilter( + expectFilterOrganizationSettingsEvents(context.Background(), orgID, settingExisting, orgScopedUsernames)..., + ) +} + +func expectFilterOrganizationSettingsEvents(ctx context.Context, orgID string, settingExisting, orgScopedUsernames bool) []eventstore.Event { + var events []eventstore.Event + if settingExisting { + events = append(events, + eventFromEventPusher( + settings.NewOrganizationSettingsAddedEvent(ctx, + &settings.NewAggregate(orgID, orgID).Aggregate, + []string{}, + orgScopedUsernames, + !orgScopedUsernames, + ), + ), + ) + } + return events +} + +func expectFilterOrgDomainPolicy(userLoginMustBeDomain bool) expect { + return expectFilter( + eventFromEventPusher( + org.NewDomainPolicyAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + userLoginMustBeDomain, false, false, + ), + ), + ) +} + +func expectFilterOrganizationScopedUsernames(userMustBeDomain bool, usernames ...string) expect { + events := make([]eventstore.Event, len(usernames)) + for i, username := range usernames { + events[i] = eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate(username, "org1").Aggregate, + username, + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email@test.ch", + userMustBeDomain, + ), + ) + } + return expectFilter( + events..., + ) +} diff --git a/internal/command/policy_org_model.go b/internal/command/policy_org_model.go index cc0c8bcd6f..9bf56aa981 100644 --- a/internal/command/policy_org_model.go +++ b/internal/command/policy_org_model.go @@ -148,7 +148,7 @@ func (wm *DomainPolicyUsernamesWriteModel) Query() *eventstore.SearchQueryBuilde Builder() } -func (wm *DomainPolicyUsernamesWriteModel) NewUsernameChangedEvents(ctx context.Context, userLoginMustBeDomain bool) []eventstore.Command { +func (wm *DomainPolicyUsernamesWriteModel) NewUsernameChangedEvents(ctx context.Context, userLoginMustBeDomain, organizationScopedUsernames, oldUserLoginMustBeDomain bool) []eventstore.Command { events := make([]eventstore.Command, 0, len(wm.Users)) for _, changeUser := range wm.Users { events = append(events, user.NewUsernameChangedEvent(ctx, @@ -156,12 +156,21 @@ func (wm *DomainPolicyUsernamesWriteModel) NewUsernameChangedEvents(ctx context. changeUser.username, wm.newUsername(changeUser.username, userLoginMustBeDomain), userLoginMustBeDomain, - user.UsernameChangedEventWithPolicyChange()), - ) + organizationScopedUsernames, + user.UsernameChangedEventWithPolicyChange(oldUserLoginMustBeDomain), + )) } return events } +func (wm *DomainPolicyUsernamesWriteModel) Usernames() []string { + usernames := make([]string, 0, len(wm.Users)) + for i, user := range wm.Users { + usernames[i] = user.username + } + return usernames +} + func (wm *DomainPolicyUsernamesWriteModel) newUsername(username string, userLoginMustBeDomain bool) string { if !userLoginMustBeDomain { // if the UserLoginMustBeDomain will be false, then it's currently true diff --git a/internal/command/user.go b/internal/command/user.go index d834169f8a..b803c5496e 100644 --- a/internal/command/user.go +++ b/internal/command/user.go @@ -43,10 +43,15 @@ func (c *Commands) ChangeUsername(ctx context.Context, orgID, userID, userName s if err = c.userValidateDomain(ctx, orgID, userName, domainPolicy.UserLoginMustBeDomain); err != nil { return nil, err } + orgScopedUsernames, err := c.checkOrganizationScopedUsernames(ctx, orgID) + if err != nil { + return nil, err + } + userAgg := UserAggregateFromWriteModel(&existingUser.WriteModel) pushedEvents, err := c.eventstore.Push(ctx, - user.NewUsernameChangedEvent(ctx, userAgg, existingUser.UserName, userName, domainPolicy.UserLoginMustBeDomain)) + user.NewUsernameChangedEvent(ctx, userAgg, existingUser.UserName, userName, domainPolicy.UserLoginMustBeDomain, orgScopedUsernames)) if err != nil { return nil, err } @@ -189,9 +194,13 @@ func (c *Commands) RemoveUser(ctx context.Context, userID, resourceOwner string, if err != nil { return nil, zerrors.ThrowPreconditionFailed(err, "COMMAND-3M9fs", "Errors.Org.DomainPolicy.NotExisting") } + orgScopedUsername, err := c.checkOrganizationScopedUsernames(ctx, existingUser.ResourceOwner) + if err != nil { + return nil, err + } var events []eventstore.Command userAgg := UserAggregateFromWriteModel(&existingUser.WriteModel) - events = append(events, user.NewUserRemovedEvent(ctx, userAgg, existingUser.UserName, existingUser.IDPLinks, domainPolicy.UserLoginMustBeDomain)) + events = append(events, user.NewUserRemovedEvent(ctx, userAgg, existingUser.UserName, existingUser.IDPLinks, domainPolicy.UserLoginMustBeDomain || orgScopedUsername)) for _, grantID := range cascadingGrantIDs { removeEvent, _, err := c.removeUserGrant(ctx, grantID, "", true, false, nil) @@ -269,6 +278,11 @@ func (c *Commands) userDomainClaimed(ctx context.Context, userID string) (events return nil, nil, err } + organizationScopedUsername, err := c.checkOrganizationScopedUsernames(ctx, existingUser.ResourceOwner) + if err != nil { + return nil, nil, err + } + id, err := c.idGenerator.Next() if err != nil { return nil, nil, err @@ -279,7 +293,8 @@ func (c *Commands) userDomainClaimed(ctx context.Context, userID string) (events userAgg, fmt.Sprintf("%s@temporary.%s", id, http_util.DomainContext(ctx).RequestedDomain()), existingUser.UserName, - domainPolicy.UserLoginMustBeDomain), + domainPolicy.UserLoginMustBeDomain || organizationScopedUsername, + ), }, changedUserGrant, nil } @@ -295,6 +310,12 @@ func (c *Commands) prepareUserDomainClaimed(ctx context.Context, filter preparat if err != nil { return nil, err } + + organizationScopedUsername, err := checkOrganizationScopedUsernames(ctx, filter, userWriteModel.ResourceOwner, nil) + if err != nil { + return nil, err + } + userAgg := UserAggregateFromWriteModel(&userWriteModel.WriteModel) id, err := c.idGenerator.Next() @@ -307,7 +328,8 @@ func (c *Commands) prepareUserDomainClaimed(ctx context.Context, filter preparat userAgg, fmt.Sprintf("%s@temporary.%s", id, http_util.DomainContext(ctx).RequestedDomain()), userWriteModel.UserName, - domainPolicy.UserLoginMustBeDomain), nil + domainPolicy.UserLoginMustBeDomain || organizationScopedUsername, + ), nil } func (c *Commands) UserDomainClaimedSent(ctx context.Context, orgID, userID string) (err error) { diff --git a/internal/command/user_human.go b/internal/command/user_human.go index 07628b9e19..e4a6148159 100644 --- a/internal/command/user_human.go +++ b/internal/command/user_human.go @@ -199,6 +199,11 @@ func (c *Commands) AddHumanCommand(human *AddHuman, orgID string, hasher *crypto return nil, err } + organizationScopedUsername, err := checkOrganizationScopedUsernames(ctx, filter, a.ResourceOwner, nil) + if err != nil { + return nil, err + } + var createCmd humanCreationCommand if human.Register { createCmd = user.NewHumanRegisteredEvent( @@ -212,7 +217,7 @@ func (c *Commands) AddHumanCommand(human *AddHuman, orgID string, hasher *crypto human.PreferredLanguage, human.Gender, human.Email.Address, - domainPolicy.UserLoginMustBeDomain, + domainPolicy.UserLoginMustBeDomain || organizationScopedUsername, "", // no user agent id available ) } else { @@ -227,7 +232,7 @@ func (c *Commands) AddHumanCommand(human *AddHuman, orgID string, hasher *crypto human.PreferredLanguage, human.Gender, human.Email.Address, - domainPolicy.UserLoginMustBeDomain, + domainPolicy.UserLoginMustBeDomain || organizationScopedUsername, ) } @@ -439,6 +444,12 @@ func (c *Commands) ImportHuman(ctx context.Context, orgID string, human *domain. if err != nil { return nil, nil, zerrors.ThrowPreconditionFailed(err, "COMMAND-2N9fs", "Errors.Org.DomainPolicy.NotFound") } + + organizationScopedUsername, err := c.checkOrganizationScopedUsernames(ctx, orgID) + if err != nil { + return nil, nil, err + } + pwPolicy, err := c.getOrgPasswordComplexityPolicy(ctx, orgID) if err != nil { return nil, nil, zerrors.ThrowPreconditionFailed(err, "COMMAND-4N8gs", "Errors.Org.PasswordComplexityPolicy.NotFound") @@ -455,7 +466,7 @@ func (c *Commands) ImportHuman(ctx context.Context, orgID string, human *domain. } } - events, userAgg, addedHuman, addedCode, code, err := c.importHuman(ctx, orgID, human, passwordless, links, domainPolicy, pwPolicy, initCodeGenerator, emailCodeGenerator, phoneCodeGenerator, passwordlessCodeGenerator) + events, userAgg, addedHuman, addedCode, code, err := c.importHuman(ctx, orgID, human, passwordless, links, domainPolicy, organizationScopedUsername, pwPolicy, initCodeGenerator, emailCodeGenerator, phoneCodeGenerator, passwordlessCodeGenerator) if err != nil { return nil, nil, err } @@ -501,7 +512,7 @@ func (c *Commands) ImportHuman(ctx context.Context, orgID string, human *domain. return writeModelToHuman(addedHuman), passwordlessCode, nil } -func (c *Commands) importHuman(ctx context.Context, orgID string, human *domain.Human, passwordless bool, links []*domain.UserIDPLink, domainPolicy *domain.DomainPolicy, pwPolicy *domain.PasswordComplexityPolicy, initCodeGenerator, emailCodeGenerator, phoneCodeGenerator, passwordlessCodeGenerator crypto.Generator) (events []eventstore.Command, userAgg *eventstore.Aggregate, humanWriteModel *HumanWriteModel, passwordlessCodeWriteModel *HumanPasswordlessInitCodeWriteModel, code string, err error) { +func (c *Commands) importHuman(ctx context.Context, orgID string, human *domain.Human, passwordless bool, links []*domain.UserIDPLink, domainPolicy *domain.DomainPolicy, orgScopedUsername bool, pwPolicy *domain.PasswordComplexityPolicy, initCodeGenerator, emailCodeGenerator, phoneCodeGenerator, passwordlessCodeGenerator crypto.Generator) (events []eventstore.Command, userAgg *eventstore.Aggregate, humanWriteModel *HumanWriteModel, passwordlessCodeWriteModel *HumanPasswordlessInitCodeWriteModel, code string, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() @@ -511,7 +522,7 @@ func (c *Commands) importHuman(ctx context.Context, orgID string, human *domain. if err = human.Normalize(); err != nil { return nil, nil, nil, nil, "", err } - events, userAgg, humanWriteModel, err = c.createHuman(ctx, orgID, human, links, passwordless, domainPolicy, pwPolicy, initCodeGenerator, emailCodeGenerator, phoneCodeGenerator) + events, userAgg, humanWriteModel, err = c.createHuman(ctx, orgID, human, links, passwordless, domainPolicy, orgScopedUsername, pwPolicy, initCodeGenerator, emailCodeGenerator, phoneCodeGenerator) if err != nil { return nil, nil, nil, nil, "", err } @@ -526,7 +537,7 @@ func (c *Commands) importHuman(ctx context.Context, orgID string, human *domain. return events, userAgg, humanWriteModel, passwordlessCodeWriteModel, code, nil } -func (c *Commands) createHuman(ctx context.Context, orgID string, human *domain.Human, links []*domain.UserIDPLink, passwordless bool, domainPolicy *domain.DomainPolicy, pwPolicy *domain.PasswordComplexityPolicy, initCodeGenerator, emailCodeGenerator, phoneCodeGenerator crypto.Generator) (events []eventstore.Command, userAgg *eventstore.Aggregate, addedHuman *HumanWriteModel, err error) { +func (c *Commands) createHuman(ctx context.Context, orgID string, human *domain.Human, links []*domain.UserIDPLink, passwordless bool, domainPolicy *domain.DomainPolicy, orgScopedUsername bool, pwPolicy *domain.PasswordComplexityPolicy, initCodeGenerator, emailCodeGenerator, phoneCodeGenerator crypto.Generator) (events []eventstore.Command, userAgg *eventstore.Aggregate, addedHuman *HumanWriteModel, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() @@ -559,7 +570,7 @@ func (c *Commands) createHuman(ctx context.Context, orgID string, human *domain. // TODO: adlerhurst maybe we could simplify the code below userAgg = UserAggregateFromWriteModelCtx(ctx, &addedHuman.WriteModel) - events = append(events, createAddHumanEvent(ctx, userAgg, human, domainPolicy.UserLoginMustBeDomain)) + events = append(events, createAddHumanEvent(ctx, userAgg, human, domainPolicy.UserLoginMustBeDomain, orgScopedUsername)) for _, link := range links { event, err := c.addUserIDPLink(ctx, userAgg, link, false) @@ -619,7 +630,7 @@ func (c *Commands) HumanSkipMFAInit(ctx context.Context, userID, resourceowner s } // TODO: adlerhurst maybe we can simplify createAddHumanEvent and createRegisterHumanEvent -func createAddHumanEvent(ctx context.Context, aggregate *eventstore.Aggregate, human *domain.Human, userLoginMustBeDomain bool) *user.HumanAddedEvent { +func createAddHumanEvent(ctx context.Context, aggregate *eventstore.Aggregate, human *domain.Human, userLoginMustBeDomain, orgScopedUsername bool) *user.HumanAddedEvent { addEvent := user.NewHumanAddedEvent( ctx, aggregate, @@ -631,7 +642,7 @@ func createAddHumanEvent(ctx context.Context, aggregate *eventstore.Aggregate, h human.PreferredLanguage, human.Gender, human.EmailAddress, - userLoginMustBeDomain, + userLoginMustBeDomain || orgScopedUsername, ) if human.Phone != nil { addEvent.AddPhoneData(human.PhoneNumber) diff --git a/internal/command/user_human_test.go b/internal/command/user_human_test.go index 1ef3e2aab6..a5641d69c0 100644 --- a/internal/command/user_human_test.go +++ b/internal/command/user_human_test.go @@ -190,6 +190,7 @@ func TestCommandSide_AddHuman(t *testing.T) { ), ), ), + expectFilterOrganizationSettings("org1", false, false), expectFilter(), expectFilter(), ), @@ -231,6 +232,7 @@ func TestCommandSide_AddHuman(t *testing.T) { ), ), ), + expectFilterOrganizationSettings("org1", false, false), expectPush( user.NewHumanAddedEvent(context.Background(), &userAgg.Aggregate, @@ -299,6 +301,7 @@ func TestCommandSide_AddHuman(t *testing.T) { ), ), ), + expectFilterOrganizationSettings("org1", false, false), expectPush( user.NewHumanAddedEvent(context.Background(), &userAgg.Aggregate, @@ -368,6 +371,77 @@ func TestCommandSide_AddHuman(t *testing.T) { ), ), ), + expectFilterOrganizationSettings("org1", false, false), + expectPush( + user.NewHumanAddedEvent(context.Background(), + &userAgg.Aggregate, + "username", + "firstname", + "lastname", + "", + "firstname lastname", + AllowedLanguage, + domain.GenderUnspecified, + "email@test.ch", + true, + ), + user.NewHumanInitialCodeAddedEvent(context.Background(), + &userAgg.Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("userinit"), + }, + time.Hour*1, + "", + ), + ), + ), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), + codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + newCode: mockEncryptedCode("userinit", time.Hour), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + human: &AddHuman{ + Username: "username", + FirstName: "firstname", + LastName: "lastname", + Email: Email{ + Address: "email@test.ch", + }, + PreferredLanguage: AllowedLanguage, + }, + secretGenerator: GetMockSecretGenerator(t), + allowInitMail: true, + }, + res: res{ + want: &domain.ObjectDetails{ + Sequence: 0, + EventDate: time.Time{}, + ResourceOwner: "org1", + }, + wantID: "user1", + }, + }, + { + name: "add human (with initial code), orgScopedUsername, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + expectFilter( + eventFromEventPusher( + org.NewDomainPolicyAddedEvent(context.Background(), + &userAgg.Aggregate, + false, + true, + true, + ), + ), + ), + expectFilterOrganizationSettings("org1", true, true), expectPush( user.NewHumanAddedEvent(context.Background(), &userAgg.Aggregate, @@ -437,6 +511,7 @@ func TestCommandSide_AddHuman(t *testing.T) { ), ), ), + expectFilterOrganizationSettings("org1", false, false), expectFilter( eventFromEventPusher( org.NewPasswordComplexityPolicyAddedEvent(context.Background(), @@ -507,6 +582,7 @@ func TestCommandSide_AddHuman(t *testing.T) { ), ), ), + expectFilterOrganizationSettings("org1", false, false), expectFilter( eventFromEventPusher( org.NewPasswordComplexityPolicyAddedEvent(context.Background(), @@ -580,6 +656,7 @@ func TestCommandSide_AddHuman(t *testing.T) { ), ), ), + expectFilterOrganizationSettings("org1", false, false), expectFilter( eventFromEventPusher( org.NewPasswordComplexityPolicyAddedEvent(context.Background(), @@ -654,6 +731,7 @@ func TestCommandSide_AddHuman(t *testing.T) { ), ), ), + expectFilterOrganizationSettings("org1", false, false), expectFilter( eventFromEventPusher( org.NewPasswordComplexityPolicyAddedEvent(context.Background(), @@ -714,6 +792,7 @@ func TestCommandSide_AddHuman(t *testing.T) { ), ), ), + expectFilterOrganizationSettings("org1", false, false), expectFilter( eventFromEventPusher( org.NewPasswordComplexityPolicyAddedEvent(context.Background(), @@ -777,6 +856,7 @@ func TestCommandSide_AddHuman(t *testing.T) { ), ), ), + expectFilterOrganizationSettings("org1", false, false), expectFilter( eventFromEventPusher( org.NewPasswordComplexityPolicyAddedEvent(context.Background(), @@ -840,6 +920,7 @@ func TestCommandSide_AddHuman(t *testing.T) { ), ), ), + expectFilterOrganizationSettings("org1", false, false), expectFilter( eventFromEventPusher( org.NewPasswordComplexityPolicyAddedEvent(context.Background(), @@ -963,6 +1044,7 @@ func TestCommandSide_AddHuman(t *testing.T) { ), ), ), + expectFilterOrganizationSettings("org1", false, false), expectFilter( eventFromEventPusher( org.NewPasswordComplexityPolicyAddedEvent(context.Background(), @@ -1042,6 +1124,7 @@ func TestCommandSide_AddHuman(t *testing.T) { ), ), ), + expectFilterOrganizationSettings("org1", false, false), expectFilter( eventFromEventPusher( org.NewPasswordComplexityPolicyAddedEvent(context.Background(), @@ -1151,6 +1234,7 @@ func TestCommandSide_AddHuman(t *testing.T) { ), ), ), + expectFilterOrganizationSettings("org1", false, false), expectPush( newAddHumanEvent("", false, true, "+41711234567", AllowedLanguage), user.NewHumanInitialCodeAddedEvent( @@ -1216,6 +1300,7 @@ func TestCommandSide_AddHuman(t *testing.T) { ), ), ), + expectFilterOrganizationSettings("org1", false, false), expectFilter( eventFromEventPusher( org.NewPasswordComplexityPolicyAddedEvent(context.Background(), @@ -1326,6 +1411,7 @@ func TestCommandSide_AddHuman(t *testing.T) { ), ), ), + expectFilterOrganizationSettings("org1", false, false), expectPush( newAddHumanEvent("", false, true, "", AllowedLanguage), user.NewHumanInitialCodeAddedEvent( @@ -1518,6 +1604,7 @@ func TestCommandSide_ImportHuman(t *testing.T) { ), ), ), + expectFilterOrganizationSettings("org1", false, false), expectFilter(), expectFilter(), ), @@ -1557,6 +1644,7 @@ func TestCommandSide_ImportHuman(t *testing.T) { ), ), ), + expectFilterOrganizationSettings("org1", false, false), expectFilter( eventFromEventPusher( org.NewPasswordComplexityPolicyAddedEvent(context.Background(), @@ -1602,6 +1690,94 @@ func TestCommandSide_ImportHuman(t *testing.T) { ), ), ), + expectFilterOrganizationSettings("org1", false, false), + expectFilter( + eventFromEventPusher( + org.NewPasswordComplexityPolicyAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + 1, + false, + false, + false, + false, + ), + ), + ), + expectPush( + newAddHumanEvent("$plain$x$password", true, true, "", AllowedLanguage), + user.NewHumanInitialCodeAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("a"), + }, + time.Hour*1, + "", + ), + ), + ), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), + userPasswordHasher: mockPasswordHasher("x"), + }, + args{ + ctx: context.Background(), + orgID: "org1", + human: &domain.Human{ + Username: "username", + Password: &domain.Password{ + SecretString: "password", + ChangeRequired: true, + }, + Profile: &domain.Profile{ + FirstName: "firstname", + LastName: "lastname", + PreferredLanguage: AllowedLanguage, + }, + Email: &domain.Email{ + EmailAddress: "email@test.ch", + }, + }, + secretGenerator: GetMockSecretGenerator(t), + } + }, + res: res{ + wantHuman: &domain.Human{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "user1", + ResourceOwner: "org1", + }, + Username: "username", + Profile: &domain.Profile{ + FirstName: "firstname", + LastName: "lastname", + DisplayName: "firstname lastname", + PreferredLanguage: AllowedLanguage, + }, + Email: &domain.Email{ + EmailAddress: "email@test.ch", + }, + State: domain.UserStateInitial, + }, + }, + }, + { + name: "add human (with password and initial code), orgScopedUsername, ok", + given: func(t *testing.T) (fields, args) { + return fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + org.NewDomainPolicyAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + false, + true, + true, + ), + ), + ), + expectFilterOrganizationSettings("org1", true, true), expectFilter( eventFromEventPusher( org.NewPasswordComplexityPolicyAddedEvent(context.Background(), @@ -1688,6 +1864,7 @@ func TestCommandSide_ImportHuman(t *testing.T) { ), ), ), + expectFilterOrganizationSettings("org1", false, false), expectFilter( eventFromEventPusher( org.NewPasswordComplexityPolicyAddedEvent(context.Background(), @@ -1768,6 +1945,7 @@ func TestCommandSide_ImportHuman(t *testing.T) { ), ), ), + expectFilterOrganizationSettings("org1", false, false), expectFilter( eventFromEventPusher( org.NewPasswordComplexityPolicyAddedEvent(context.Background(), @@ -1867,6 +2045,7 @@ func TestCommandSide_ImportHuman(t *testing.T) { ), ), ), + expectFilterOrganizationSettings("org1", false, false), expectFilter( eventFromEventPusher( org.NewPasswordComplexityPolicyAddedEvent(context.Background(), @@ -1970,6 +2149,7 @@ func TestCommandSide_ImportHuman(t *testing.T) { ), ), ), + expectFilterOrganizationSettings("org1", false, false), expectFilter( eventFromEventPusher( org.NewPasswordComplexityPolicyAddedEvent(context.Background(), @@ -2105,6 +2285,7 @@ func TestCommandSide_ImportHuman(t *testing.T) { ), ), ), + expectFilterOrganizationSettings("org1", false, false), expectFilter( eventFromEventPusher( org.NewPasswordComplexityPolicyAddedEvent(context.Background(), @@ -2200,6 +2381,7 @@ func TestCommandSide_ImportHuman(t *testing.T) { ), ), ), + expectFilterOrganizationSettings("org1", false, false), expectFilter( eventFromEventPusher( org.NewPasswordComplexityPolicyAddedEvent(context.Background(), @@ -2285,6 +2467,7 @@ func TestCommandSide_ImportHuman(t *testing.T) { ), ), ), + expectFilterOrganizationSettings("org1", false, false), expectFilter( eventFromEventPusher( org.NewPasswordComplexityPolicyAddedEvent(context.Background(), @@ -2371,6 +2554,7 @@ func TestCommandSide_ImportHuman(t *testing.T) { ), ), ), + expectFilterOrganizationSettings("org1", false, false), expectFilter( eventFromEventPusher( org.NewPasswordComplexityPolicyAddedEvent(context.Background(), @@ -2516,6 +2700,7 @@ func TestCommandSide_ImportHuman(t *testing.T) { ), ), ), + expectFilterOrganizationSettings("org1", false, false), expectFilter( eventFromEventPusher( org.NewPasswordComplexityPolicyAddedEvent(context.Background(), @@ -2659,6 +2844,7 @@ func TestCommandSide_ImportHuman(t *testing.T) { ), ), ), + expectFilterOrganizationSettings("org1", false, false), expectFilter( eventFromEventPusher( org.NewPasswordComplexityPolicyAddedEvent(context.Background(), @@ -2831,6 +3017,7 @@ func TestCommandSide_ImportHuman(t *testing.T) { ), ), ), + expectFilterOrganizationSettings("org1", false, false), expectFilter( eventFromEventPusher( org.NewPasswordComplexityPolicyAddedEvent(context.Background(), @@ -3003,6 +3190,7 @@ func TestCommandSide_ImportHuman(t *testing.T) { ), ), ), + expectFilterOrganizationSettings("org1", false, false), expectFilter( eventFromEventPusher( org.NewPasswordComplexityPolicyAddedEvent(context.Background(), @@ -3182,6 +3370,7 @@ func TestCommandSide_ImportHuman(t *testing.T) { ), ), ), + expectFilterOrganizationSettings("org1", false, false), expectFilter( eventFromEventPusher( org.NewPasswordComplexityPolicyAddedEvent(context.Background(), @@ -3829,6 +4018,10 @@ func TestAddHumanCommand(t *testing.T) { ), }, nil }). + Append( + func(ctx context.Context, queryFactory *eventstore.SearchQueryBuilder) ([]eventstore.Event, error) { + return expectFilterOrganizationSettingsEvents(ctx, "org1", false, false), nil + }). Append( func(ctx context.Context, queryFactory *eventstore.SearchQueryBuilder) ([]eventstore.Event, error) { return []eventstore.Event{ @@ -3882,6 +4075,87 @@ func TestAddHumanCommand(t *testing.T) { ), }, nil }). + Append( + func(ctx context.Context, queryFactory *eventstore.SearchQueryBuilder) ([]eventstore.Event, error) { + return expectFilterOrganizationSettingsEvents(ctx, "org1", false, false), nil + }). + Append( + func(ctx context.Context, queryFactory *eventstore.SearchQueryBuilder) ([]eventstore.Event, error) { + return []eventstore.Event{ + org.NewPasswordComplexityPolicyAddedEvent( + ctx, + &org.NewAggregate("id").Aggregate, + 2, + false, + false, + false, + false, + ), + }, nil + }). + Filter(), + }, + want: Want{ + Commands: []eventstore.Command{ + func() *user.HumanAddedEvent { + event := user.NewHumanAddedEvent( + context.Background(), + &agg.Aggregate, + "username", + "gigi", + "giraffe", + "", + "gigi giraffe", + AllowedLanguage, + 0, + "support@zitadel.com", + true, + ) + event.AddPasswordData("$plain$x$password", false) + return event + }(), + user.NewHumanEmailVerifiedEvent(context.Background(), &agg.Aggregate), + }, + }, + }, + { + name: "correct, orgScopedUsername", + fields: fields{ + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "id"), + }, + args: args{ + human: &AddHuman{ + Email: Email{Address: "support@zitadel.com", Verified: true}, + FirstName: "gigi", + LastName: "giraffe", + Password: "password", + Username: "username", + PreferredLanguage: AllowedLanguage, + }, + orgID: "ro", + hasher: mockPasswordHasher("x"), + codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + filter: NewMultiFilter().Append( + func(ctx context.Context, queryFactory *eventstore.SearchQueryBuilder) ([]eventstore.Event, error) { + return []eventstore.Event{}, nil + }). + Append( + func(ctx context.Context, queryFactory *eventstore.SearchQueryBuilder) ([]eventstore.Event, error) { + return []eventstore.Event{ + org.NewDomainPolicyAddedEvent( + ctx, + &org.NewAggregate("id").Aggregate, + true, + true, + true, + ), + }, nil + }). + Append( + func(ctx context.Context, queryFactory *eventstore.SearchQueryBuilder) ([]eventstore.Event, error) { + // never set, as only used in creation of instance and org + return expectFilterOrganizationSettingsEvents(ctx, "org1", false, false), nil + }). Append( func(ctx context.Context, queryFactory *eventstore.SearchQueryBuilder) ([]eventstore.Event, error) { return []eventstore.Event{ @@ -3953,6 +4227,10 @@ func TestAddHumanCommand(t *testing.T) { ), }, nil }). + Append( + func(ctx context.Context, queryFactory *eventstore.SearchQueryBuilder) ([]eventstore.Event, error) { + return expectFilterOrganizationSettingsEvents(ctx, "org1", false, false), nil + }). Append( func(ctx context.Context, queryFactory *eventstore.SearchQueryBuilder) ([]eventstore.Event, error) { return []eventstore.Event{ @@ -4025,6 +4303,10 @@ func TestAddHumanCommand(t *testing.T) { ), }, nil }). + Append( + func(ctx context.Context, queryFactory *eventstore.SearchQueryBuilder) ([]eventstore.Event, error) { + return expectFilterOrganizationSettingsEvents(ctx, "org1", false, false), nil + }). Append( func(ctx context.Context, queryFactory *eventstore.SearchQueryBuilder) ([]eventstore.Event, error) { return []eventstore.Event{ @@ -4097,6 +4379,10 @@ func TestAddHumanCommand(t *testing.T) { ), }, nil }). + Append( + func(ctx context.Context, queryFactory *eventstore.SearchQueryBuilder) ([]eventstore.Event, error) { + return expectFilterOrganizationSettingsEvents(ctx, "org1", false, false), nil + }). Append( func(ctx context.Context, queryFactory *eventstore.SearchQueryBuilder) ([]eventstore.Event, error) { return []eventstore.Event{ diff --git a/internal/command/user_machine.go b/internal/command/user_machine.go index 75ed43ee69..c281617000 100644 --- a/internal/command/user_machine.go +++ b/internal/command/user_machine.go @@ -61,8 +61,12 @@ func AddMachineCommand(a *user.Aggregate, machine *Machine) preparation.Validati if err != nil { return nil, zerrors.ThrowPreconditionFailed(err, "COMMAND-3M9fs", "Errors.Org.DomainPolicy.NotFound") } + orgScopedUsername, err := checkOrganizationScopedUsernames(ctx, filter, a.ResourceOwner, nil) + if err != nil { + return nil, err + } return []eventstore.Command{ - user.NewMachineAddedEvent(ctx, &a.Aggregate, machine.Username, machine.Name, machine.Description, domainPolicy.UserLoginMustBeDomain, machine.AccessTokenType), + user.NewMachineAddedEvent(ctx, &a.Aggregate, machine.Username, machine.Name, machine.Description, domainPolicy.UserLoginMustBeDomain || orgScopedUsername, machine.AccessTokenType), }, nil }, nil } diff --git a/internal/command/user_machine_test.go b/internal/command/user_machine_test.go index 6d94154a42..32664b5c1b 100644 --- a/internal/command/user_machine_test.go +++ b/internal/command/user_machine_test.go @@ -121,6 +121,54 @@ func TestCommandSide_AddMachine(t *testing.T) { ), ), ), + expectFilterOrganizationSettings("org1", false, false), + expectPush( + user.NewMachineAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "name", + "description", + true, + domain.OIDCTokenTypeBearer, + ), + ), + ), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), + }, + args: args{ + ctx: context.Background(), + machine: &Machine{ + ObjectRoot: models.ObjectRoot{ + ResourceOwner: "org1", + }, + Description: "description", + Name: "name", + Username: "username", + }, + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + name: "add machine, orgScopedUsername, ok", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter(), + expectFilter( + eventFromEventPusher( + org.NewDomainPolicyAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + false, + true, + true, + ), + ), + ), + expectFilterOrganizationSettings("org1", true, true), expectPush( user.NewMachineAddedEvent(context.Background(), &user.NewAggregate("user1", "org1").Aggregate, @@ -167,6 +215,7 @@ func TestCommandSide_AddMachine(t *testing.T) { ), ), ), + expectFilterOrganizationSettings("org1", false, false), expectPush( user.NewMachineAddedEvent(context.Background(), &user.NewAggregate("optionalID1", "org1").Aggregate, @@ -214,6 +263,7 @@ func TestCommandSide_AddMachine(t *testing.T) { ), ), ), + expectFilterOrganizationSettings("org1", false, false), expectPush( user.NewMachineAddedEvent(context.Background(), &user.NewAggregate("aggregateID", "org1").Aggregate, @@ -264,6 +314,7 @@ func TestCommandSide_AddMachine(t *testing.T) { ), ), ), + expectFilterOrganizationSettings("org1", false, false), expectPush( user.NewMachineAddedEvent(context.Background(), &user.NewAggregate("aggregateID", "org1").Aggregate, @@ -312,6 +363,7 @@ func TestCommandSide_AddMachine(t *testing.T) { ), ), ), + expectFilterOrganizationSettings("org1", false, false), expectPush( user.NewMachineAddedEvent(context.Background(), &user.NewAggregate("aggregateID", "org1").Aggregate, @@ -361,6 +413,7 @@ func TestCommandSide_AddMachine(t *testing.T) { ), ), ), + expectFilterOrganizationSettings("org1", false, false), expectPush( user.NewMachineAddedEvent(context.Background(), &user.NewAggregate("user1", "org1").Aggregate, @@ -436,6 +489,7 @@ func TestCommandSide_AddMachine(t *testing.T) { ), ), ), + expectFilterOrganizationSettings("org1", false, false), expectPush( user.NewMachineAddedEvent(context.Background(), &user.NewAggregate("user1", "org1").Aggregate, @@ -489,6 +543,7 @@ func TestCommandSide_AddMachine(t *testing.T) { ), ), ), + expectFilterOrganizationSettings("org1", false, false), expectPush( user.NewMachineAddedEvent(context.Background(), &user.NewAggregate("user1", "org1").Aggregate, diff --git a/internal/command/user_test.go b/internal/command/user_test.go index 6a1597fc8b..c971f0939d 100644 --- a/internal/command/user_test.go +++ b/internal/command/user_test.go @@ -295,12 +295,14 @@ func TestCommandSide_UsernameChange(t *testing.T) { ), ), ), + expectFilterOrganizationSettings("org1", false, false), expectPush( user.NewUsernameChangedEvent(context.Background(), &user.NewAggregate("user1", "org1").Aggregate, "username", "test@test.ch", true, + false, ), ), ), @@ -317,6 +319,61 @@ func TestCommandSide_UsernameChange(t *testing.T) { }, }, }, + { + name: "email as username, orgScopedUsername, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email@test.ch", + true, + ), + ), + ), + expectFilter(), + expectFilter( + eventFromEventPusher( + instance.NewDomainPolicyAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + false, + true, + true, + ), + ), + ), + expectFilterOrganizationSettings("org1", true, true), + expectPush( + user.NewUsernameChangedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "test", + false, + true, + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + userID: "user1", + username: "test", + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, { name: "email as username, verified domain, ok", fields: fields{ @@ -348,6 +405,7 @@ func TestCommandSide_UsernameChange(t *testing.T) { ), ), ), + expectFilterOrganizationSettings("org1", false, false), expectFilter( eventFromEventPusher( org.NewDomainVerifiedEvent(context.Background(), @@ -362,6 +420,7 @@ func TestCommandSide_UsernameChange(t *testing.T) { "username", "test@test.ch", false, + false, ), ), ), @@ -409,12 +468,14 @@ func TestCommandSide_UsernameChange(t *testing.T) { ), ), ), + expectFilterOrganizationSettings("org1", false, false), expectPush( user.NewUsernameChangedEvent(context.Background(), &user.NewAggregate("user1", "org1").Aggregate, "username", "username1", true, + false, ), ), ), @@ -462,11 +523,13 @@ func TestCommandSide_UsernameChange(t *testing.T) { ), ), ), + expectFilterOrganizationSettings("org1", false, false), expectPush( user.NewUsernameChangedEvent(context.Background(), &user.NewAggregate("user1", "org1").Aggregate, "username", "username1", + false, true, ), ), @@ -506,7 +569,7 @@ func TestCommandSide_UsernameChange(t *testing.T) { func TestCommandSide_DeactivateUser(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(t *testing.T) *eventstore.Eventstore } type ( args struct { @@ -528,9 +591,7 @@ func TestCommandSide_DeactivateUser(t *testing.T) { { name: "userid missing, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), }, args: args{ ctx: context.Background(), @@ -544,8 +605,7 @@ func TestCommandSide_DeactivateUser(t *testing.T) { { name: "user not existing, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), }, @@ -561,8 +621,7 @@ func TestCommandSide_DeactivateUser(t *testing.T) { { name: "user already inactive, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -598,8 +657,7 @@ func TestCommandSide_DeactivateUser(t *testing.T) { { name: "deactivate user, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -638,7 +696,7 @@ func TestCommandSide_DeactivateUser(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), } got, err := r.DeactivateUser(tt.args.ctx, tt.args.userID, tt.args.orgID) if tt.res.err == nil { @@ -656,7 +714,7 @@ func TestCommandSide_DeactivateUser(t *testing.T) { func TestCommandSide_ReactivateUser(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(t *testing.T) *eventstore.Eventstore } type ( args struct { @@ -678,9 +736,7 @@ func TestCommandSide_ReactivateUser(t *testing.T) { { name: "userid missing, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), }, args: args{ ctx: context.Background(), @@ -694,8 +750,7 @@ func TestCommandSide_ReactivateUser(t *testing.T) { { name: "user not existing, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), }, @@ -711,8 +766,7 @@ func TestCommandSide_ReactivateUser(t *testing.T) { { name: "user already active, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -743,8 +797,7 @@ func TestCommandSide_ReactivateUser(t *testing.T) { { name: "reactivate user, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -787,7 +840,7 @@ func TestCommandSide_ReactivateUser(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), } got, err := r.ReactivateUser(tt.args.ctx, tt.args.userID, tt.args.orgID) if tt.res.err == nil { @@ -805,7 +858,7 @@ func TestCommandSide_ReactivateUser(t *testing.T) { func TestCommandSide_LockUser(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(t *testing.T) *eventstore.Eventstore } type ( args struct { @@ -827,9 +880,7 @@ func TestCommandSide_LockUser(t *testing.T) { { name: "userid missing, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), }, args: args{ ctx: context.Background(), @@ -843,8 +894,7 @@ func TestCommandSide_LockUser(t *testing.T) { { name: "user not existing, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), }, @@ -860,8 +910,7 @@ func TestCommandSide_LockUser(t *testing.T) { { name: "user already locked, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -897,8 +946,7 @@ func TestCommandSide_LockUser(t *testing.T) { { name: "lock user, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -937,7 +985,7 @@ func TestCommandSide_LockUser(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), } got, err := r.LockUser(tt.args.ctx, tt.args.userID, tt.args.orgID) if tt.res.err == nil { @@ -955,7 +1003,7 @@ func TestCommandSide_LockUser(t *testing.T) { func TestCommandSide_UnlockUser(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(t *testing.T) *eventstore.Eventstore } type ( args struct { @@ -977,9 +1025,7 @@ func TestCommandSide_UnlockUser(t *testing.T) { { name: "userid missing, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), }, args: args{ ctx: context.Background(), @@ -993,8 +1039,7 @@ func TestCommandSide_UnlockUser(t *testing.T) { { name: "user not existing, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), }, @@ -1010,8 +1055,7 @@ func TestCommandSide_UnlockUser(t *testing.T) { { name: "user already active, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -1042,8 +1086,7 @@ func TestCommandSide_UnlockUser(t *testing.T) { { name: "unlock user, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -1086,7 +1129,7 @@ func TestCommandSide_UnlockUser(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), } got, err := r.UnlockUser(tt.args.ctx, tt.args.userID, tt.args.orgID) if tt.res.err == nil { @@ -1104,7 +1147,7 @@ func TestCommandSide_UnlockUser(t *testing.T) { func TestCommandSide_RemoveUser(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(t *testing.T) *eventstore.Eventstore } type ( args struct { @@ -1129,9 +1172,7 @@ func TestCommandSide_RemoveUser(t *testing.T) { { name: "userid missing, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), }, args: args{ ctx: context.Background(), @@ -1145,8 +1186,7 @@ func TestCommandSide_RemoveUser(t *testing.T) { { name: "user not existing, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), }, @@ -1162,8 +1202,7 @@ func TestCommandSide_RemoveUser(t *testing.T) { { name: "org iam policy not found, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -1196,8 +1235,7 @@ func TestCommandSide_RemoveUser(t *testing.T) { { name: "remove user, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -1225,6 +1263,60 @@ func TestCommandSide_RemoveUser(t *testing.T) { ), ), ), + expectFilterOrganizationSettings("org1", false, false), + expectPush( + user.NewUserRemovedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + nil, + true, + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + userID: "user1", + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + name: "remove user, orgScopedUsername, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email@test.ch", + true, + ), + ), + ), + expectFilter(), + expectFilter( + eventFromEventPusher( + instance.NewDomainPolicyAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + false, + true, + true, + ), + ), + ), + expectFilterOrganizationSettings("org1", true, true), expectPush( user.NewUserRemovedEvent(context.Background(), &user.NewAggregate("user1", "org1").Aggregate, @@ -1249,8 +1341,7 @@ func TestCommandSide_RemoveUser(t *testing.T) { { name: "remove user with erxternal idp, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -1286,6 +1377,7 @@ func TestCommandSide_RemoveUser(t *testing.T) { ), ), ), + expectFilterOrganizationSettings("org1", false, false), expectPush( user.NewUserRemovedEvent(context.Background(), &user.NewAggregate("user1", "org1").Aggregate, @@ -1315,8 +1407,7 @@ func TestCommandSide_RemoveUser(t *testing.T) { { name: "remove user with user memberships, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -1344,6 +1435,7 @@ func TestCommandSide_RemoveUser(t *testing.T) { ), ), ), + expectFilterOrganizationSettings("org1", false, false), expectPush( user.NewUserRemovedEvent(context.Background(), &user.NewAggregate("user1", "org1").Aggregate, @@ -1418,7 +1510,7 @@ func TestCommandSide_RemoveUser(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), } got, err := r.RemoveUser(tt.args.ctx, tt.args.userID, tt.args.orgID, tt.args.cascadeUserMemberships, tt.args.cascadeUserGrants...) if tt.res.err == nil { @@ -1434,7 +1526,7 @@ func TestCommandSide_RemoveUser(t *testing.T) { func TestCommands_RevokeAccessToken(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(t *testing.T) *eventstore.Eventstore } type args struct { ctx context.Context @@ -1455,7 +1547,7 @@ func TestCommands_RevokeAccessToken(t *testing.T) { { "id missing error", fields{ - eventstoreExpect(t), + expectEventstore(), }, args{ context.Background(), @@ -1471,7 +1563,7 @@ func TestCommands_RevokeAccessToken(t *testing.T) { { "not active error", fields{ - eventstoreExpect(t, + expectEventstore( expectFilter( eventFromEventPusher( user.NewUserTokenAddedEvent(context.Background(), @@ -1507,7 +1599,7 @@ func TestCommands_RevokeAccessToken(t *testing.T) { { "active ok", fields{ - eventstoreExpect(t, + expectEventstore( expectFilter( eventFromEventPusher( user.NewUserTokenAddedEvent(context.Background(), @@ -1552,7 +1644,7 @@ func TestCommands_RevokeAccessToken(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { c := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), } got, err := c.RevokeAccessToken(tt.args.ctx, tt.args.userID, tt.args.orgID, tt.args.tokenID) if tt.res.err == nil { @@ -1570,7 +1662,7 @@ func TestCommands_RevokeAccessToken(t *testing.T) { func TestCommandSide_UserDomainClaimedSent(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(t *testing.T) *eventstore.Eventstore } type args struct { ctx context.Context @@ -1589,9 +1681,7 @@ func TestCommandSide_UserDomainClaimedSent(t *testing.T) { { name: "userid missing, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), }, args: args{ ctx: context.Background(), @@ -1604,8 +1694,7 @@ func TestCommandSide_UserDomainClaimedSent(t *testing.T) { { name: "user not existing, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), }, @@ -1621,8 +1710,7 @@ func TestCommandSide_UserDomainClaimedSent(t *testing.T) { { name: "code sent, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -1657,7 +1745,7 @@ func TestCommandSide_UserDomainClaimedSent(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), } err := r.UserDomainClaimedSent(tt.args.ctx, tt.args.resourceOwner, tt.args.userID) if tt.res.err == nil { diff --git a/internal/command/user_v2.go b/internal/command/user_v2.go index d6c5e7de53..028fda7f4b 100644 --- a/internal/command/user_v2.go +++ b/internal/command/user_v2.go @@ -2,6 +2,7 @@ package command import ( "context" + "github.com/zitadel/logging" "github.com/zitadel/zitadel/internal/domain" @@ -145,8 +146,13 @@ func (c *Commands) RemoveUserV2(ctx context.Context, userID, resourceOwner strin if err != nil { return nil, zerrors.ThrowPreconditionFailed(err, "COMMAND-l40ykb3xh2", "Errors.Org.DomainPolicy.NotExisting") } + organizationScopedUsername, err := c.checkOrganizationScopedUsernames(ctx, existingUser.ResourceOwner) + if err != nil { + return nil, err + } + var events []eventstore.Command - events = append(events, user.NewUserRemovedEvent(ctx, &existingUser.Aggregate().Aggregate, existingUser.UserName, existingUser.IDPLinks, domainPolicy.UserLoginMustBeDomain)) + events = append(events, user.NewUserRemovedEvent(ctx, &existingUser.Aggregate().Aggregate, existingUser.UserName, existingUser.IDPLinks, domainPolicy.UserLoginMustBeDomain || organizationScopedUsername)) for _, grantID := range cascadingGrantIDs { removeEvent, _, err := c.removeUserGrant(ctx, grantID, "", true, true, nil) diff --git a/internal/command/user_v2_human.go b/internal/command/user_v2_human.go index 0945ae7578..3bb54c6d07 100644 --- a/internal/command/user_v2_human.go +++ b/internal/command/user_v2_human.go @@ -165,6 +165,11 @@ func (c *Commands) AddUserHuman(ctx context.Context, resourceOwner string, human return err } + organizationScopedUsername, err := c.checkOrganizationScopedUsernames(ctx, resourceOwner) + if err != nil { + return err + } + var createCmd humanCreationCommand if human.Register { createCmd = user.NewHumanRegisteredEvent( @@ -178,7 +183,7 @@ func (c *Commands) AddUserHuman(ctx context.Context, resourceOwner string, human human.PreferredLanguage, human.Gender, human.Email.Address, - domainPolicy.UserLoginMustBeDomain, + domainPolicy.UserLoginMustBeDomain || organizationScopedUsername, human.UserAgentID, ) } else { @@ -193,7 +198,7 @@ func (c *Commands) AddUserHuman(ctx context.Context, resourceOwner string, human human.PreferredLanguage, human.Gender, human.Email.Address, - domainPolicy.UserLoginMustBeDomain, + domainPolicy.UserLoginMustBeDomain || organizationScopedUsername, ) } diff --git a/internal/command/user_v2_human_test.go b/internal/command/user_v2_human_test.go index e44e182b92..afa570cd58 100644 --- a/internal/command/user_v2_human_test.go +++ b/internal/command/user_v2_human_test.go @@ -60,6 +60,7 @@ func TestCommandSide_AddUserHuman(t *testing.T) { } userAgg := user.NewAggregate("user1", "org1") + orgAgg := org.NewAggregate("org1") cryptoAlg := crypto.CreateMockEncryptionAlg(gomock.NewController(t)) totpSecret := "TOTPSecret" @@ -191,7 +192,7 @@ func TestCommandSide_AddUserHuman(t *testing.T) { expectFilter( eventFromEventPusher( org.NewDomainPolicyAddedEvent(context.Background(), - &userAgg.Aggregate, + &orgAgg.Aggregate, true, true, true, @@ -199,6 +200,7 @@ func TestCommandSide_AddUserHuman(t *testing.T) { ), ), expectFilter(), + expectFilterOrganizationSettings("org1", false, false), expectFilter(), ), checkPermission: newMockPermissionCheckAllowed(), @@ -233,13 +235,14 @@ func TestCommandSide_AddUserHuman(t *testing.T) { expectFilter( eventFromEventPusher( org.NewDomainPolicyAddedEvent(context.Background(), - &userAgg.Aggregate, + &orgAgg.Aggregate, true, true, true, ), ), ), + expectFilterOrganizationSettings("org1", false, false), expectPush( user.NewHumanRegisteredEvent(context.Background(), &userAgg.Aggregate, @@ -335,13 +338,14 @@ func TestCommandSide_AddUserHuman(t *testing.T) { expectFilter( eventFromEventPusher( org.NewDomainPolicyAddedEvent(context.Background(), - &userAgg.Aggregate, + &orgAgg.Aggregate, true, true, true, ), ), ), + expectFilterOrganizationSettings("org1", false, false), expectPush( user.NewHumanAddedEvent(context.Background(), &userAgg.Aggregate, @@ -412,6 +416,7 @@ func TestCommandSide_AddUserHuman(t *testing.T) { ), ), ), + expectFilterOrganizationSettings("org1", false, false), expectFilter( eventFromEventPusher( org.NewPasswordComplexityPolicyAddedEvent(context.Background(), @@ -483,6 +488,7 @@ func TestCommandSide_AddUserHuman(t *testing.T) { ), ), ), + expectFilterOrganizationSettings("org1", false, false), expectPush( newAddHumanEvent("", false, true, "", language.English), user.NewHumanEmailCodeAddedEventV2(context.Background(), @@ -542,6 +548,7 @@ func TestCommandSide_AddUserHuman(t *testing.T) { ), ), ), + expectFilterOrganizationSettings("org1", false, false), expectFilter( eventFromEventPusher( org.NewPasswordComplexityPolicyAddedEvent(context.Background(), @@ -616,6 +623,7 @@ func TestCommandSide_AddUserHuman(t *testing.T) { ), ), ), + expectFilterOrganizationSettings("org1", false, false), expectFilter( eventFromEventPusher( org.NewPasswordComplexityPolicyAddedEvent(context.Background(), @@ -684,13 +692,14 @@ func TestCommandSide_AddUserHuman(t *testing.T) { expectFilter( eventFromEventPusher( org.NewDomainPolicyAddedEvent(context.Background(), - &userAgg.Aggregate, + &orgAgg.Aggregate, true, true, true, ), ), ), + expectFilterOrganizationSettings("org1", false, false), expectFilter( eventFromEventPusher( org.NewPasswordComplexityPolicyAddedEvent(context.Background(), @@ -748,13 +757,14 @@ func TestCommandSide_AddUserHuman(t *testing.T) { expectFilter( eventFromEventPusher( org.NewDomainPolicyAddedEvent(context.Background(), - &userAgg.Aggregate, + &orgAgg.Aggregate, true, true, true, ), ), ), + expectFilterOrganizationSettings("org1", false, false), expectFilter( eventFromEventPusher( org.NewPasswordComplexityPolicyAddedEvent(context.Background(), @@ -812,13 +822,14 @@ func TestCommandSide_AddUserHuman(t *testing.T) { expectFilter( eventFromEventPusher( org.NewDomainPolicyAddedEvent(context.Background(), - &userAgg.Aggregate, + &orgAgg.Aggregate, false, true, true, ), ), ), + expectFilterOrganizationSettings("org1", false, false), expectFilter( eventFromEventPusher( org.NewPasswordComplexityPolicyAddedEvent(context.Background(), @@ -876,7 +887,7 @@ func TestCommandSide_AddUserHuman(t *testing.T) { expectFilter( eventFromEventPusher( org.NewDomainPolicyAddedEvent(context.Background(), - &userAgg.Aggregate, + &orgAgg.Aggregate, false, true, true, @@ -929,7 +940,7 @@ func TestCommandSide_AddUserHuman(t *testing.T) { expectFilter( eventFromEventPusher( org.NewDomainPolicyAddedEvent(context.Background(), - &userAgg.Aggregate, + &orgAgg.Aggregate, false, true, true, @@ -944,6 +955,7 @@ func TestCommandSide_AddUserHuman(t *testing.T) { ), ), ), + expectFilterOrganizationSettings("org1", false, false), expectFilter( eventFromEventPusher( org.NewPasswordComplexityPolicyAddedEvent(context.Background(), @@ -1017,13 +1029,14 @@ func TestCommandSide_AddUserHuman(t *testing.T) { expectFilter( eventFromEventPusher( org.NewDomainPolicyAddedEvent(context.Background(), - &userAgg.Aggregate, + &orgAgg.Aggregate, true, true, true, ), ), ), + expectFilterOrganizationSettings("org1", false, false), expectFilter( eventFromEventPusher( org.NewPasswordComplexityPolicyAddedEvent(context.Background(), @@ -1127,13 +1140,14 @@ func TestCommandSide_AddUserHuman(t *testing.T) { expectFilter( eventFromEventPusher( org.NewDomainPolicyAddedEvent(context.Background(), - &userAgg.Aggregate, + &orgAgg.Aggregate, true, true, true, ), ), ), + expectFilterOrganizationSettings("org1", false, false), expectFilter( eventFromEventPusher( org.NewPasswordComplexityPolicyAddedEvent(context.Background(), @@ -1232,13 +1246,14 @@ func TestCommandSide_AddUserHuman(t *testing.T) { expectFilter( eventFromEventPusher( org.NewDomainPolicyAddedEvent(context.Background(), - &userAgg.Aggregate, + &orgAgg.Aggregate, true, true, true, ), ), ), + expectFilterOrganizationSettings("org1", false, false), expectPush( newAddHumanEvent("", false, true, "+41711234567", language.English), user.NewHumanInitialCodeAddedEvent( @@ -1305,6 +1320,7 @@ func TestCommandSide_AddUserHuman(t *testing.T) { ), ), ), + expectFilterOrganizationSettings("org1", false, false), expectFilter( eventFromEventPusher( org.NewPasswordComplexityPolicyAddedEvent(context.Background(), @@ -1409,13 +1425,14 @@ func TestCommandSide_AddUserHuman(t *testing.T) { expectFilter( eventFromEventPusher( org.NewDomainPolicyAddedEvent(context.Background(), - &userAgg.Aggregate, + &orgAgg.Aggregate, true, true, true, ), ), ), + expectFilterOrganizationSettings("org1", false, false), expectPush( newAddHumanEvent("", false, true, "", language.English), user.NewHumanInitialCodeAddedEvent( @@ -1479,13 +1496,14 @@ func TestCommandSide_AddUserHuman(t *testing.T) { expectFilter( eventFromEventPusher( org.NewDomainPolicyAddedEvent(context.Background(), - &userAgg.Aggregate, + &orgAgg.Aggregate, true, true, true, ), ), ), + expectFilterOrganizationSettings("org1", false, false), expectFilter( eventFromEventPusher( org.NewGoogleIDPAddedEvent(context.Background(), @@ -1566,13 +1584,14 @@ func TestCommandSide_AddUserHuman(t *testing.T) { expectFilter( eventFromEventPusher( org.NewDomainPolicyAddedEvent(context.Background(), - &userAgg.Aggregate, + &orgAgg.Aggregate, true, true, true, ), ), ), + expectFilterOrganizationSettings("org1", false, false), expectFilter( eventFromEventPusher( org.NewGoogleIDPAddedEvent(context.Background(), @@ -1646,13 +1665,98 @@ func TestCommandSide_AddUserHuman(t *testing.T) { expectFilter( eventFromEventPusher( org.NewDomainPolicyAddedEvent(context.Background(), - &userAgg.Aggregate, + &orgAgg.Aggregate, true, true, true, ), ), ), + expectFilterOrganizationSettings("org1", false, false), + expectPush( + user.NewHumanRegisteredEvent(context.Background(), + &userAgg.Aggregate, + "username", + "firstname", + "lastname", + "", + "firstname lastname", + language.English, + domain.GenderUnspecified, + "email@test.ch", + true, + "userAgentID", + ), + user.NewHumanInitialCodeAddedEvent(context.Background(), + &userAgg.Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("userinit"), + }, + time.Hour*1, + "authRequestID", + ), + user.NewHumanOTPAddedEvent(context.Background(), + &userAgg.Aggregate, + totpSecretEnc, + ), + user.NewHumanOTPVerifiedEvent(context.Background(), + &userAgg.Aggregate, + "", + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), + newCode: mockEncryptedCode("userinit", time.Hour), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + human: &AddHuman{ + Username: "username", + FirstName: "firstname", + LastName: "lastname", + Email: Email{ + Address: "email@test.ch", + }, + PreferredLanguage: language.English, + Register: true, + UserAgentID: "userAgentID", + AuthRequestID: "authRequestID", + TOTPSecret: totpSecret, + }, + secretGenerator: GetMockSecretGenerator(t), + allowInitMail: true, + codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + }, + res: res{ + want: &domain.ObjectDetails{ + Sequence: 0, + EventDate: time.Time{}, + ResourceOwner: "org1", + }, + wantID: "user1", + }, + }, + { + name: "register human with TOTPSecret, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + expectFilter( + eventFromEventPusher( + org.NewDomainPolicyAddedEvent(context.Background(), + &orgAgg.Aggregate, + true, + true, + true, + ), + ), + ), + expectFilterOrganizationSettings("org1", false, false), expectPush( user.NewHumanRegisteredEvent(context.Background(), &userAgg.Aggregate, @@ -1729,7 +1833,7 @@ func TestCommandSide_AddUserHuman(t *testing.T) { expectFilter( eventFromEventPusher( org.NewDomainPolicyAddedEvent(context.Background(), - &userAgg.Aggregate, + &orgAgg.Aggregate, false, true, true, @@ -1775,14 +1879,14 @@ func TestCommandSide_AddUserHuman(t *testing.T) { }, }, { - name: "register human (validate domain), ok", + name: "register human (validate domain), orgScopedUsername, ok", fields: fields{ eventstore: expectEventstore( expectFilter(), expectFilter( eventFromEventPusher( org.NewDomainPolicyAddedEvent(context.Background(), - &userAgg.Aggregate, + &orgAgg.Aggregate, false, true, true, @@ -1797,6 +1901,7 @@ func TestCommandSide_AddUserHuman(t *testing.T) { ), ), ), + expectFilterOrganizationSettings("org1", true, true), expectPush( user.NewHumanRegisteredEvent(context.Background(), &userAgg.Aggregate, @@ -1808,7 +1913,7 @@ func TestCommandSide_AddUserHuman(t *testing.T) { language.English, domain.GenderUnspecified, "email@example.com", - false, + true, "userAgentID", ), user.NewHumanInitialCodeAddedEvent(context.Background(), @@ -1864,7 +1969,7 @@ func TestCommandSide_AddUserHuman(t *testing.T) { expectFilter( eventFromEventPusher( org.NewDomainPolicyAddedEvent(context.Background(), - &userAgg.Aggregate, + &orgAgg.Aggregate, false, true, true, @@ -1886,6 +1991,7 @@ func TestCommandSide_AddUserHuman(t *testing.T) { ), ), ), + expectFilterOrganizationSettings("org1", false, false), expectPush( user.NewHumanRegisteredEvent(context.Background(), &userAgg.Aggregate, @@ -1953,7 +2059,7 @@ func TestCommandSide_AddUserHuman(t *testing.T) { expectFilter( eventFromEventPusher( org.NewDomainPolicyAddedEvent(context.Background(), - &userAgg.Aggregate, + &orgAgg.Aggregate, false, true, true, @@ -1979,6 +2085,7 @@ func TestCommandSide_AddUserHuman(t *testing.T) { ), ), ), + expectFilterOrganizationSettings("org1", false, false), expectPush( user.NewHumanRegisteredEvent(context.Background(), &userAgg.Aggregate, @@ -2107,6 +2214,7 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) { } userAgg := user.NewAggregate("user1", "org1") + orgAgg := user.NewAggregate("org1", "org1") tests := []struct { name string @@ -2199,19 +2307,68 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) { expectFilter( eventFromEventPusher( org.NewDomainPolicyAddedEvent(context.Background(), - &userAgg.Aggregate, + &orgAgg.Aggregate, true, true, true, ), ), ), + expectFilterOrganizationSettings("org1", false, false), expectPush( user.NewUsernameChangedEvent(context.Background(), &userAgg.Aggregate, "username", "changed", true, + false, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + human: &ChangeHuman{ + Username: gu.Ptr("changed"), + }, + }, + res: res{ + want: &domain.ObjectDetails{ + Sequence: 0, + EventDate: time.Time{}, + ResourceOwner: "org1", + }, + }, + }, + { + name: "change human username, orgScopedUsername, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + newAddHumanEvent("$plain$x$password", true, false, "", language.English), + ), + ), + expectFilter( + eventFromEventPusher( + org.NewDomainPolicyAddedEvent(context.Background(), + &orgAgg.Aggregate, + false, + true, + true, + ), + ), + ), + expectFilterOrganizationSettings("org1", true, true), + expectPush( + user.NewUsernameChangedEvent(context.Background(), + &userAgg.Aggregate, + "username", + "changed", + false, + true, ), ), ), diff --git a/internal/command/user_v2_machine_test.go b/internal/command/user_v2_machine_test.go index 14df4bfae7..c5883286b8 100644 --- a/internal/command/user_v2_machine_test.go +++ b/internal/command/user_v2_machine_test.go @@ -32,6 +32,8 @@ func TestCommandSide_ChangeUserMachine(t *testing.T) { } userAgg := user.NewAggregate("user1", "org1") + orgAgg := org.NewAggregate("org1") + userAddedEvent := user.NewMachineAddedEvent(context.Background(), &userAgg.Aggregate, "username", @@ -101,19 +103,65 @@ func TestCommandSide_ChangeUserMachine(t *testing.T) { expectFilter( eventFromEventPusher( org.NewDomainPolicyAddedEvent(context.Background(), - &userAgg.Aggregate, + &orgAgg.Aggregate, true, true, true, ), ), ), + expectFilterOrganizationSettings("org1", false, false), expectPush( user.NewUsernameChangedEvent(context.Background(), &userAgg.Aggregate, "username", "changed", true, + false, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + machine: &ChangeMachine{ + Username: gu.Ptr("changed"), + }, + }, + res: res{ + want: &domain.ObjectDetails{ + Sequence: 0, + EventDate: time.Time{}, + ResourceOwner: "org1", + }, + }, + }, { + name: "change machine username, orgScopedUsername, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher(userAddedEvent), + ), + expectFilter( + eventFromEventPusher( + org.NewDomainPolicyAddedEvent(context.Background(), + &orgAgg.Aggregate, + false, + true, + true, + ), + ), + ), + expectFilterOrganizationSettings("org1", true, true), + expectPush( + user.NewUsernameChangedEvent(context.Background(), + &userAgg.Aggregate, + "username", + "changed", + false, + true, ), ), ), diff --git a/internal/command/user_v2_model_test.go b/internal/command/user_v2_model_test.go index cb12c8fcda..ecaa2db400 100644 --- a/internal/command/user_v2_model_test.go +++ b/internal/command/user_v2_model_test.go @@ -343,6 +343,7 @@ func TestCommandSide_userExistsWriteModel(t *testing.T) { "username", "changed", true, + false, ), ), ), diff --git a/internal/command/user_v2_test.go b/internal/command/user_v2_test.go index 685ad95253..f878569a06 100644 --- a/internal/command/user_v2_test.go +++ b/internal/command/user_v2_test.go @@ -18,6 +18,7 @@ import ( ) func TestCommandSide_LockUserV2(t *testing.T) { + userAgg := &user.NewAggregate("user1", "org1").Aggregate type fields struct { eventstore func(*testing.T) *eventstore.Eventstore checkPermission domain.PermissionCheck @@ -79,7 +80,7 @@ func TestCommandSide_LockUserV2(t *testing.T) { expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), - &user.NewAggregate("user1", "org1").Aggregate, + userAgg, "username", "firstname", "lastname", @@ -93,7 +94,7 @@ func TestCommandSide_LockUserV2(t *testing.T) { ), eventFromEventPusher( user.NewUserLockedEvent(context.Background(), - &user.NewAggregate("user1", "org1").Aggregate, + userAgg, ), ), ), @@ -117,7 +118,7 @@ func TestCommandSide_LockUserV2(t *testing.T) { expectFilter( eventFromEventPusher( user.NewMachineAddedEvent(context.Background(), - &user.NewAggregate("user1", "org1").Aggregate, + userAgg, "username", "name", "description", @@ -127,7 +128,7 @@ func TestCommandSide_LockUserV2(t *testing.T) { ), eventFromEventPusher( user.NewUserLockedEvent(context.Background(), - &user.NewAggregate("user1", "org1").Aggregate, + userAgg, ), ), ), @@ -151,7 +152,7 @@ func TestCommandSide_LockUserV2(t *testing.T) { expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), - &user.NewAggregate("user1", "org1").Aggregate, + userAgg, "username", "firstname", "lastname", @@ -166,7 +167,7 @@ func TestCommandSide_LockUserV2(t *testing.T) { ), expectPush( user.NewUserLockedEvent(context.Background(), - &user.NewAggregate("user1", "org1").Aggregate, + userAgg, ), ), ), @@ -189,7 +190,7 @@ func TestCommandSide_LockUserV2(t *testing.T) { expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), - &user.NewAggregate("user1", "org1").Aggregate, + userAgg, "username", "firstname", "lastname", @@ -222,7 +223,7 @@ func TestCommandSide_LockUserV2(t *testing.T) { expectFilter( eventFromEventPusher( user.NewMachineAddedEvent(context.Background(), - &user.NewAggregate("user1", "org1").Aggregate, + userAgg, "username", "name", "description", @@ -233,7 +234,7 @@ func TestCommandSide_LockUserV2(t *testing.T) { ), expectPush( user.NewUserLockedEvent(context.Background(), - &user.NewAggregate("user1", "org1").Aggregate, + userAgg, ), ), ), @@ -271,6 +272,7 @@ func TestCommandSide_LockUserV2(t *testing.T) { } func TestCommandSide_UnlockUserV2(t *testing.T) { + userAgg := &user.NewAggregate("user1", "org1").Aggregate type fields struct { eventstore func(*testing.T) *eventstore.Eventstore checkPermission domain.PermissionCheck @@ -332,7 +334,7 @@ func TestCommandSide_UnlockUserV2(t *testing.T) { expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), - &user.NewAggregate("user1", "org1").Aggregate, + userAgg, "username", "firstname", "lastname", @@ -364,7 +366,7 @@ func TestCommandSide_UnlockUserV2(t *testing.T) { eventstore: expectEventstore( expectFilter( user.NewMachineAddedEvent(context.Background(), - &user.NewAggregate("user1", "org1").Aggregate, + userAgg, "username", "name", "description", @@ -392,7 +394,7 @@ func TestCommandSide_UnlockUserV2(t *testing.T) { expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), - &user.NewAggregate("user1", "org1").Aggregate, + userAgg, "username", "firstname", "lastname", @@ -406,12 +408,12 @@ func TestCommandSide_UnlockUserV2(t *testing.T) { ), eventFromEventPusher( user.NewUserLockedEvent(context.Background(), - &user.NewAggregate("user1", "org1").Aggregate), + userAgg), ), ), expectPush( user.NewUserUnlockedEvent(context.Background(), - &user.NewAggregate("user1", "org1").Aggregate, + userAgg, ), ), ), @@ -434,7 +436,7 @@ func TestCommandSide_UnlockUserV2(t *testing.T) { expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), - &user.NewAggregate("user1", "org1").Aggregate, + userAgg, "username", "firstname", "lastname", @@ -448,7 +450,7 @@ func TestCommandSide_UnlockUserV2(t *testing.T) { ), eventFromEventPusher( user.NewUserLockedEvent(context.Background(), - &user.NewAggregate("user1", "org1").Aggregate), + userAgg), ), ), ), @@ -471,7 +473,7 @@ func TestCommandSide_UnlockUserV2(t *testing.T) { expectFilter( eventFromEventPusher( user.NewMachineAddedEvent(context.Background(), - &user.NewAggregate("user1", "org1").Aggregate, + userAgg, "username", "name", "description", @@ -481,12 +483,12 @@ func TestCommandSide_UnlockUserV2(t *testing.T) { ), eventFromEventPusher( user.NewUserLockedEvent(context.Background(), - &user.NewAggregate("user1", "org1").Aggregate), + userAgg), ), ), expectPush( user.NewUserUnlockedEvent(context.Background(), - &user.NewAggregate("user1", "org1").Aggregate, + userAgg, ), ), ), @@ -524,6 +526,7 @@ func TestCommandSide_UnlockUserV2(t *testing.T) { } func TestCommandSide_DeactivateUserV2(t *testing.T) { + userAgg := &user.NewAggregate("user1", "org1").Aggregate type fields struct { eventstore func(*testing.T) *eventstore.Eventstore checkPermission domain.PermissionCheck @@ -585,7 +588,7 @@ func TestCommandSide_DeactivateUserV2(t *testing.T) { expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), - &user.NewAggregate("user1", "org1").Aggregate, + userAgg, "username", "firstname", "lastname", @@ -599,7 +602,7 @@ func TestCommandSide_DeactivateUserV2(t *testing.T) { ), eventFromEventPusher( user.NewHumanInitialCodeAddedEvent(context.Background(), - &user.NewAggregate("user1", "org1").Aggregate, + userAgg, nil, time.Hour*1, "", ), @@ -625,7 +628,7 @@ func TestCommandSide_DeactivateUserV2(t *testing.T) { expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), - &user.NewAggregate("user1", "org1").Aggregate, + userAgg, "username", "firstname", "lastname", @@ -639,7 +642,7 @@ func TestCommandSide_DeactivateUserV2(t *testing.T) { ), eventFromEventPusher( user.NewUserDeactivatedEvent(context.Background(), - &user.NewAggregate("user1", "org1").Aggregate, + userAgg, ), ), ), @@ -663,7 +666,7 @@ func TestCommandSide_DeactivateUserV2(t *testing.T) { expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), - &user.NewAggregate("user1", "org1").Aggregate, + userAgg, "username", "firstname", "lastname", @@ -677,13 +680,13 @@ func TestCommandSide_DeactivateUserV2(t *testing.T) { ), eventFromEventPusher( user.NewHumanInitializedCheckSucceededEvent(context.Background(), - &user.NewAggregate("user1", "org1").Aggregate, + userAgg, ), ), ), expectPush( user.NewUserDeactivatedEvent(context.Background(), - &user.NewAggregate("user1", "org1").Aggregate, + userAgg, ), ), ), @@ -706,7 +709,7 @@ func TestCommandSide_DeactivateUserV2(t *testing.T) { expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), - &user.NewAggregate("user1", "org1").Aggregate, + userAgg, "username", "firstname", "lastname", @@ -720,7 +723,7 @@ func TestCommandSide_DeactivateUserV2(t *testing.T) { ), eventFromEventPusher( user.NewHumanInitializedCheckSucceededEvent(context.Background(), - &user.NewAggregate("user1", "org1").Aggregate, + userAgg, ), ), ), @@ -744,7 +747,7 @@ func TestCommandSide_DeactivateUserV2(t *testing.T) { expectFilter( eventFromEventPusher( user.NewMachineAddedEvent(context.Background(), - &user.NewAggregate("user1", "org1").Aggregate, + userAgg, "username", "name", "description", @@ -754,7 +757,7 @@ func TestCommandSide_DeactivateUserV2(t *testing.T) { ), eventFromEventPusher( user.NewUserDeactivatedEvent(context.Background(), - &user.NewAggregate("user1", "org1").Aggregate, + userAgg, ), ), ), @@ -778,7 +781,7 @@ func TestCommandSide_DeactivateUserV2(t *testing.T) { expectFilter( eventFromEventPusher( user.NewMachineAddedEvent(context.Background(), - &user.NewAggregate("user1", "org1").Aggregate, + userAgg, "username", "name", "description", @@ -789,7 +792,7 @@ func TestCommandSide_DeactivateUserV2(t *testing.T) { ), expectPush( user.NewUserDeactivatedEvent(context.Background(), - &user.NewAggregate("user1", "org1").Aggregate, + userAgg, ), ), ), @@ -827,6 +830,7 @@ func TestCommandSide_DeactivateUserV2(t *testing.T) { } func TestCommandSide_ReactivateUserV2(t *testing.T) { + userAgg := &user.NewAggregate("user1", "org1").Aggregate type fields struct { eventstore func(*testing.T) *eventstore.Eventstore checkPermission domain.PermissionCheck @@ -888,7 +892,7 @@ func TestCommandSide_ReactivateUserV2(t *testing.T) { expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), - &user.NewAggregate("user1", "org1").Aggregate, + userAgg, "username", "firstname", "lastname", @@ -921,7 +925,7 @@ func TestCommandSide_ReactivateUserV2(t *testing.T) { expectFilter( eventFromEventPusher( user.NewMachineAddedEvent(context.Background(), - &user.NewAggregate("user1", "org1").Aggregate, + userAgg, "username", "name", "description", @@ -950,7 +954,7 @@ func TestCommandSide_ReactivateUserV2(t *testing.T) { expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), - &user.NewAggregate("user1", "org1").Aggregate, + userAgg, "username", "firstname", "lastname", @@ -964,12 +968,12 @@ func TestCommandSide_ReactivateUserV2(t *testing.T) { ), eventFromEventPusher( user.NewUserDeactivatedEvent(context.Background(), - &user.NewAggregate("user1", "org1").Aggregate), + userAgg), ), ), expectPush( user.NewUserReactivatedEvent(context.Background(), - &user.NewAggregate("user1", "org1").Aggregate, + userAgg, ), ), ), @@ -992,7 +996,7 @@ func TestCommandSide_ReactivateUserV2(t *testing.T) { expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), - &user.NewAggregate("user1", "org1").Aggregate, + userAgg, "username", "firstname", "lastname", @@ -1006,7 +1010,7 @@ func TestCommandSide_ReactivateUserV2(t *testing.T) { ), eventFromEventPusher( user.NewUserDeactivatedEvent(context.Background(), - &user.NewAggregate("user1", "org1").Aggregate), + userAgg), ), ), ), @@ -1029,7 +1033,7 @@ func TestCommandSide_ReactivateUserV2(t *testing.T) { expectFilter( eventFromEventPusher( user.NewMachineAddedEvent(context.Background(), - &user.NewAggregate("user1", "org1").Aggregate, + userAgg, "username", "name", "description", @@ -1039,12 +1043,12 @@ func TestCommandSide_ReactivateUserV2(t *testing.T) { ), eventFromEventPusher( user.NewUserDeactivatedEvent(context.Background(), - &user.NewAggregate("user1", "org1").Aggregate), + userAgg), ), ), expectPush( user.NewUserReactivatedEvent(context.Background(), - &user.NewAggregate("user1", "org1").Aggregate, + userAgg, ), ), ), @@ -1084,6 +1088,8 @@ func TestCommandSide_ReactivateUserV2(t *testing.T) { func TestCommandSide_RemoveUserV2(t *testing.T) { ctxUserID := "ctxUserID" ctx := authz.SetCtxData(context.Background(), authz.CtxData{UserID: ctxUserID}) + userAgg := &user.NewAggregate("user1", "org1").Aggregate + orgAgg := &org.NewAggregate("org1").Aggregate type fields struct { eventstore func(*testing.T) *eventstore.Eventstore checkPermission domain.PermissionCheck @@ -1144,7 +1150,7 @@ func TestCommandSide_RemoveUserV2(t *testing.T) { expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(ctx, - &user.NewAggregate("user1", "org1").Aggregate, + userAgg, "username", "firstname", "lastname", @@ -1158,7 +1164,7 @@ func TestCommandSide_RemoveUserV2(t *testing.T) { ), eventFromEventPusher( user.NewUserRemovedEvent(ctx, - &user.NewAggregate("user1", "org1").Aggregate, + userAgg, "username", nil, true, @@ -1184,7 +1190,7 @@ func TestCommandSide_RemoveUserV2(t *testing.T) { expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(ctx, - &user.NewAggregate("user1", "org1").Aggregate, + userAgg, "username", "firstname", "lastname", @@ -1199,17 +1205,18 @@ func TestCommandSide_RemoveUserV2(t *testing.T) { ), expectFilter( eventFromEventPusher( - org.NewDomainPolicyAddedEvent(ctx, - &user.NewAggregate("user1", "org1").Aggregate, + org.NewDomainPolicyAddedEvent(context.Background(), + orgAgg, true, true, true, ), ), ), + expectFilterOrganizationSettings("org1", false, false), expectPush( user.NewUserRemovedEvent(ctx, - &user.NewAggregate("user1", "org1").Aggregate, + userAgg, "username", nil, true, @@ -1234,7 +1241,7 @@ func TestCommandSide_RemoveUserV2(t *testing.T) { expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(ctx, - &user.NewAggregate("user1", "org1").Aggregate, + userAgg, "username", "firstname", "lastname", @@ -1248,7 +1255,7 @@ func TestCommandSide_RemoveUserV2(t *testing.T) { ), eventFromEventPusher( user.NewHumanInitializedCheckSucceededEvent(ctx, - &user.NewAggregate("user1", "org1").Aggregate, + userAgg, ), ), ), @@ -1269,7 +1276,7 @@ func TestCommandSide_RemoveUserV2(t *testing.T) { expectFilter( eventFromEventPusher( user.NewMachineAddedEvent(ctx, - &user.NewAggregate("user1", "org1").Aggregate, + userAgg, "username", "name", "description", @@ -1279,7 +1286,7 @@ func TestCommandSide_RemoveUserV2(t *testing.T) { ), eventFromEventPusher( user.NewUserRemovedEvent(ctx, - &user.NewAggregate("user1", "org1").Aggregate, + userAgg, "username", nil, true, @@ -1304,7 +1311,7 @@ func TestCommandSide_RemoveUserV2(t *testing.T) { expectFilter( eventFromEventPusher( user.NewMachineAddedEvent(ctx, - &user.NewAggregate("user1", "org1").Aggregate, + userAgg, "username", "name", "description", @@ -1315,17 +1322,18 @@ func TestCommandSide_RemoveUserV2(t *testing.T) { ), expectFilter( eventFromEventPusher( - org.NewDomainPolicyAddedEvent(ctx, - &user.NewAggregate("user1", "org1").Aggregate, + org.NewDomainPolicyAddedEvent(context.Background(), + orgAgg, true, true, true, ), ), ), + expectFilterOrganizationSettings("org1", false, false), expectPush( user.NewUserRemovedEvent(ctx, - &user.NewAggregate("user1", "org1").Aggregate, + userAgg, "username", nil, true, @@ -1366,13 +1374,14 @@ func TestCommandSide_RemoveUserV2(t *testing.T) { expectFilter( eventFromEventPusher( org.NewDomainPolicyAddedEvent(ctx, - &user.NewAggregate(ctxUserID, "org1").Aggregate, + orgAgg, true, true, true, ), ), ), + expectFilterOrganizationSettings("org1", false, false), expectPush( user.NewUserRemovedEvent(ctx, &user.NewAggregate(ctxUserID, "org1").Aggregate, diff --git a/internal/command/user_v2_username.go b/internal/command/user_v2_username.go index a9132aceb9..cfec4f3b0f 100644 --- a/internal/command/user_v2_username.go +++ b/internal/command/user_v2_username.go @@ -18,10 +18,21 @@ func (c *Commands) changeUsername(ctx context.Context, cmds []eventstore.Command if err != nil { return cmds, zerrors.ThrowPreconditionFailed(err, "COMMAND-79pv6e1q62", "Errors.Org.DomainPolicy.NotExisting") } + + organizationScopedUsername, err := c.checkOrganizationScopedUsernames(ctx, orgID) + if err != nil { + return cmds, err + } + if err = c.userValidateDomain(ctx, orgID, userName, domainPolicy.UserLoginMustBeDomain); err != nil { return cmds, err } return append(cmds, - user.NewUsernameChangedEvent(ctx, &wm.Aggregate().Aggregate, wm.UserName, userName, domainPolicy.UserLoginMustBeDomain), + user.NewUsernameChangedEvent(ctx, &wm.Aggregate().Aggregate, + wm.UserName, + userName, + domainPolicy.UserLoginMustBeDomain, + organizationScopedUsername, + ), ), nil } diff --git a/internal/domain/organization_settings.go b/internal/domain/organization_settings.go new file mode 100644 index 0000000000..3d9ed819fe --- /dev/null +++ b/internal/domain/organization_settings.go @@ -0,0 +1,19 @@ +package domain + +type OrganizationSettingsState int32 + +const ( + OrganizationSettingsStateUnspecified OrganizationSettingsState = iota + OrganizationSettingsStateActive + OrganizationSettingsStateRemoved + + organizationSettingsStateCount +) + +func (c OrganizationSettingsState) Valid() bool { + return c >= 0 && c < organizationSettingsStateCount +} + +func (s OrganizationSettingsState) Exists() bool { + return s.Valid() && s != OrganizationSettingsStateUnspecified && s != OrganizationSettingsStateRemoved +} diff --git a/internal/domain/permission.go b/internal/domain/permission.go index 1405991dae..39738b1e84 100644 --- a/internal/domain/permission.go +++ b/internal/domain/permission.go @@ -65,6 +65,9 @@ const ( PermissionUserGrantWrite = "user.grant.write" PermissionUserGrantRead = "user.grant.read" PermissionUserGrantDelete = "user.grant.delete" + PermissionIAMPolicyWrite = "iam.policy.write" + PermissionIAMPolicyDelete = "iam.policy.delete" + PermissionPolicyRead = "policy.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 a89e4fa621..d4e57d06d0 100644 --- a/internal/integration/client.go +++ b/internal/integration/client.go @@ -405,6 +405,27 @@ func (i *Instance) CreateOrganizationWithUserID(ctx context.Context, name, userI return resp } +func (i *Instance) SetOrganizationSettings(ctx context.Context, t *testing.T, orgID string, organizationScopedUsernames bool) *settings_v2beta.SetOrganizationSettingsResponse { + resp, err := i.Client.SettingsV2beta.SetOrganizationSettings(ctx, + &settings_v2beta.SetOrganizationSettingsRequest{ + OrganizationId: orgID, + OrganizationScopedUsernames: gu.Ptr(organizationScopedUsernames), + }, + ) + require.NoError(t, err) + return resp +} + +func (i *Instance) DeleteOrganizationSettings(ctx context.Context, t *testing.T, orgID string) *settings_v2beta.DeleteOrganizationSettingsResponse { + resp, err := i.Client.SettingsV2beta.DeleteOrganizationSettings(ctx, + &settings_v2beta.DeleteOrganizationSettingsRequest{ + OrganizationId: orgID, + }, + ) + require.NoError(t, err) + return resp +} + func (i *Instance) CreateHumanUserVerified(ctx context.Context, org, email, phone string) *user_v2.AddHumanUserResponse { resp, err := i.Client.UserV2.AddHumanUser(ctx, &user_v2.AddHumanUserRequest{ Organization: &object.Organization{ diff --git a/internal/query/administrators.go b/internal/query/administrators.go index 20bb955bbd..0128c0f25e 100644 --- a/internal/query/administrators.go +++ b/internal/query/administrators.go @@ -185,7 +185,7 @@ func (q *Queries) searchAdministrators(ctx context.Context, queries *MembershipS eq := sq.Eq{membershipInstanceID.identifier(): authz.GetInstance(ctx).InstanceID()} stmt, args, err := queries.toQuery(query).Where(eq).ToSql() if err != nil { - return nil, zerrors.ThrowInvalidArgument(err, "QUERY-TODO", "Errors.Query.InvalidRequest") + return nil, zerrors.ThrowInvalidArgument(err, "QUERY-xhEnpLFNpJ", "Errors.Query.InvalidRequest") } latestState, err := q.latestState(ctx, orgMemberTable, instanceMemberTable, projectMemberTable, projectGrantMemberTable) if err != nil { @@ -335,7 +335,7 @@ func prepareAdministratorsQuery(ctx context.Context, queries *MembershipSearchQu } if err := rows.Close(); err != nil { - return nil, zerrors.ThrowInternal(err, "QUERY-TODO", "Errors.Query.CloseRows") + return nil, zerrors.ThrowInternal(err, "QUERY-ajYcn0eK7f", "Errors.Query.CloseRows") } return &Administrators{ diff --git a/internal/query/organization_settings.go b/internal/query/organization_settings.go new file mode 100644 index 0000000000..2670fb4f8b --- /dev/null +++ b/internal/query/organization_settings.go @@ -0,0 +1,196 @@ +package query + +import ( + "context" + "database/sql" + "slices" + "time" + + sq "github.com/Masterminds/squirrel" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/query/projection" + "github.com/zitadel/zitadel/internal/telemetry/tracing" + "github.com/zitadel/zitadel/internal/zerrors" +) + +var ( + organizationSettingsTable = table{ + name: projection.OrganizationSettingsTable, + instanceIDCol: projection.OrganizationSettingsInstanceIDCol, + } + OrganizationSettingsColumnID = Column{ + name: projection.OrganizationSettingsIDCol, + table: organizationSettingsTable, + } + OrganizationSettingsColumnCreationDate = Column{ + name: projection.OrganizationSettingsCreationDateCol, + table: organizationSettingsTable, + } + OrganizationSettingsColumnChangeDate = Column{ + name: projection.OrganizationSettingsChangeDateCol, + table: organizationSettingsTable, + } + OrganizationSettingsColumnResourceOwner = Column{ + name: projection.OrganizationSettingsResourceOwnerCol, + table: organizationSettingsTable, + } + OrganizationSettingsColumnInstanceID = Column{ + name: projection.OrganizationSettingsInstanceIDCol, + table: organizationSettingsTable, + } + OrganizationSettingsColumnSequence = Column{ + name: projection.OrganizationSettingsSequenceCol, + table: organizationSettingsTable, + } + OrganizationSettingsColumnOrganizationScopedUsernames = Column{ + name: projection.OrganizationSettingsOrganizationScopedUsernamesCol, + table: organizationSettingsTable, + } +) + +type OrganizationSettingsList struct { + SearchResponse + OrganizationSettingsList []*OrganizationSettings +} + +func organizationSettingsListCheckPermission(ctx context.Context, organizationSettingsList *OrganizationSettingsList, permissionCheck domain.PermissionCheck) { + organizationSettingsList.OrganizationSettingsList = slices.DeleteFunc(organizationSettingsList.OrganizationSettingsList, + func(organizationSettings *OrganizationSettings) bool { + return organizationSettingsCheckPermission(ctx, organizationSettings.ResourceOwner, organizationSettings.ID, permissionCheck) != nil + }, + ) +} + +func organizationSettingsCheckPermission(ctx context.Context, resourceOwner string, id string, permissionCheck domain.PermissionCheck) error { + return permissionCheck(ctx, domain.PermissionPolicyRead, resourceOwner, id) +} + +type OrganizationSettings struct { + ID string + CreationDate time.Time + ChangeDate time.Time + ResourceOwner string + Sequence uint64 + + OrganizationScopedUsernames bool +} + +type OrganizationSettingsSearchQueries struct { + SearchRequest + Queries []SearchQuery +} + +func (q *OrganizationSettingsSearchQueries) toQuery(query sq.SelectBuilder) sq.SelectBuilder { + query = q.SearchRequest.toQuery(query) + for _, q := range q.Queries { + query = q.toQuery(query) + } + return query +} + +func organizationSettingsPermissionCheckV2(ctx context.Context, query sq.SelectBuilder, enabled bool, queries *OrganizationSettingsSearchQueries) sq.SelectBuilder { + if !enabled { + return query + } + join, args := PermissionClause( + ctx, + OrganizationSettingsColumnID, + domain.PermissionPolicyRead, + SingleOrgPermissionOption(queries.Queries), + ) + return query.JoinClause(join, args...) +} + +func (q *Queries) SearchOrganizationSettings(ctx context.Context, queries *OrganizationSettingsSearchQueries, permissionCheck domain.PermissionCheck) (*OrganizationSettingsList, error) { + permissionCheckV2 := PermissionV2(ctx, permissionCheck) + settings, err := q.searchOrganizationSettings(ctx, queries, permissionCheckV2) + if err != nil { + return nil, err + } + if permissionCheck != nil && !authz.GetFeatures(ctx).PermissionCheckV2 { + organizationSettingsListCheckPermission(ctx, settings, permissionCheck) + } + return settings, nil +} + +func (q *Queries) searchOrganizationSettings(ctx context.Context, queries *OrganizationSettingsSearchQueries, permissionCheckV2 bool) (settingsList *OrganizationSettingsList, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + query, scan := prepareOrganizationSettingsListQuery() + query = organizationSettingsPermissionCheckV2(ctx, query, permissionCheckV2, queries) + eq := sq.Eq{OrganizationSettingsColumnInstanceID.identifier(): authz.GetInstance(ctx).InstanceID()} + stmt, args, err := queries.toQuery(query).Where(eq).ToSql() + if err != nil { + return nil, zerrors.ThrowInvalidArgument(err, "QUERY-qNPeOXlMwj", "Errors.Query.InvalidRequest") + } + + err = q.client.QueryContext(ctx, func(rows *sql.Rows) error { + settingsList, err = scan(rows) + return err + }, stmt, args...) + if err != nil { + return nil, err + } + return settingsList, nil +} + +func NewOrganizationSettingsOrganizationIDSearchQuery(ids []string) (SearchQuery, error) { + list := make([]interface{}, len(ids)) + for i, value := range ids { + list[i] = value + } + return NewListQuery(OrganizationSettingsColumnID, list, ListIn) +} + +func NewOrganizationSettingsOrganizationScopedUsernamesSearchQuery(organizationScopedUsernames bool) (SearchQuery, error) { + return NewBoolQuery(OrganizationSettingsColumnOrganizationScopedUsernames, organizationScopedUsernames) +} + +func prepareOrganizationSettingsListQuery() (sq.SelectBuilder, func(*sql.Rows) (*OrganizationSettingsList, error)) { + return sq.Select( + OrganizationSettingsColumnID.identifier(), + OrganizationSettingsColumnCreationDate.identifier(), + OrganizationSettingsColumnChangeDate.identifier(), + OrganizationSettingsColumnResourceOwner.identifier(), + OrganizationSettingsColumnSequence.identifier(), + OrganizationSettingsColumnOrganizationScopedUsernames.identifier(), + countColumn.identifier(), + ).From(organizationSettingsTable.identifier()). + PlaceholderFormat(sq.Dollar), + func(rows *sql.Rows) (*OrganizationSettingsList, error) { + settingsList := make([]*OrganizationSettings, 0) + var ( + count uint64 + ) + for rows.Next() { + settings := new(OrganizationSettings) + err := rows.Scan( + &settings.ID, + &settings.CreationDate, + &settings.ChangeDate, + &settings.ResourceOwner, + &settings.Sequence, + &settings.OrganizationScopedUsernames, + &count, + ) + if err != nil { + return nil, err + } + settingsList = append(settingsList, settings) + } + + if err := rows.Close(); err != nil { + return nil, zerrors.ThrowInternal(err, "QUERY-mmC1K0t5Fq", "Errors.Query.CloseRows") + } + + return &OrganizationSettingsList{ + OrganizationSettingsList: settingsList, + SearchResponse: SearchResponse{ + Count: count, + }, + }, nil + } +} diff --git a/internal/query/organization_settings_test.go b/internal/query/organization_settings_test.go new file mode 100644 index 0000000000..7ee370c548 --- /dev/null +++ b/internal/query/organization_settings_test.go @@ -0,0 +1,180 @@ +package query + +import ( + "database/sql" + "database/sql/driver" + "errors" + "fmt" + "regexp" + "testing" +) + +var ( + prepareOrganizationSettingsListStmt = `SELECT projections.organization_settings.id,` + + ` projections.organization_settings.creation_date,` + + ` projections.organization_settings.change_date,` + + ` projections.organization_settings.resource_owner,` + + ` projections.organization_settings.sequence,` + + ` projections.organization_settings.organization_scoped_usernames,` + + ` COUNT(*) OVER ()` + + ` FROM projections.organization_settings` + prepareOrganizationSettingsListCols = []string{ + "id", + "creation_date", + "change_date", + "resource_owner", + "sequence", + "organization_scoped_usernames", + "count", + } +) + +func Test_OrganizationSettingsListPrepares(t *testing.T) { + type want struct { + sqlExpectations sqlExpectation + err checkErr + } + tests := []struct { + name string + prepare interface{} + want want + object interface{} + }{ + { + name: "prepareOrganizationSettingsListQuery no result", + prepare: prepareOrganizationSettingsListQuery, + want: want{ + sqlExpectations: mockQueries( + regexp.QuoteMeta(prepareOrganizationSettingsListStmt), + nil, + nil, + ), + }, + object: &OrganizationSettingsList{OrganizationSettingsList: []*OrganizationSettings{}}, + }, + { + name: "prepareOrganizationSettingsListQuery one result", + prepare: prepareOrganizationSettingsListQuery, + want: want{ + sqlExpectations: mockQueries( + regexp.QuoteMeta(prepareOrganizationSettingsListStmt), + prepareOrganizationSettingsListCols, + [][]driver.Value{ + { + "id", + testNow, + testNow, + "ro", + uint64(20211108), + true, + }, + }, + ), + }, + object: &OrganizationSettingsList{ + SearchResponse: SearchResponse{ + Count: 1, + }, + OrganizationSettingsList: []*OrganizationSettings{ + { + ID: "id", + CreationDate: testNow, + ChangeDate: testNow, + ResourceOwner: "ro", + Sequence: 20211108, + OrganizationScopedUsernames: true, + }, + }, + }, + }, + { + name: "prepareOrganizationSettingsListQuery multiple result", + prepare: prepareOrganizationSettingsListQuery, + want: want{ + sqlExpectations: mockQueries( + regexp.QuoteMeta(prepareOrganizationSettingsListStmt), + prepareOrganizationSettingsListCols, + [][]driver.Value{ + { + "id-1", + testNow, + testNow, + "ro", + uint64(20211108), + true, + }, + { + "id-2", + testNow, + testNow, + "ro", + uint64(20211108), + false, + }, + { + "id-3", + testNow, + testNow, + "ro", + uint64(20211108), + true, + }, + }, + ), + }, + object: &OrganizationSettingsList{ + SearchResponse: SearchResponse{ + Count: 3, + }, + OrganizationSettingsList: []*OrganizationSettings{ + { + ID: "id-1", + CreationDate: testNow, + ChangeDate: testNow, + ResourceOwner: "ro", + Sequence: 20211108, + OrganizationScopedUsernames: true, + }, + { + ID: "id-2", + CreationDate: testNow, + ChangeDate: testNow, + ResourceOwner: "ro", + Sequence: 20211108, + OrganizationScopedUsernames: false, + }, + { + ID: "id-3", + CreationDate: testNow, + ChangeDate: testNow, + ResourceOwner: "ro", + Sequence: 20211108, + OrganizationScopedUsernames: true, + }, + }, + }, + }, + { + name: "prepareOrganizationSettingsListQuery sql err", + prepare: prepareOrganizationSettingsListQuery, + want: want{ + sqlExpectations: mockQueryErr( + regexp.QuoteMeta(prepareOrganizationSettingsListStmt), + sql.ErrConnDone, + ), + err: func(err error) (error, bool) { + if !errors.Is(err, sql.ErrConnDone) { + return fmt.Errorf("err should be sql.ErrConnDone got: %w", err), false + } + return nil, true + }, + }, + object: (*OrganizationSettingsList)(nil), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assertPrepare(t, tt.prepare, tt.object, tt.want.sqlExpectations, tt.want.err) + }) + } +} diff --git a/internal/query/projection/organization_settings.go b/internal/query/projection/organization_settings.go new file mode 100644 index 0000000000..c97bb2691a --- /dev/null +++ b/internal/query/projection/organization_settings.go @@ -0,0 +1,141 @@ +package projection + +import ( + "context" + + "github.com/zitadel/zitadel/internal/eventstore" + old_handler "github.com/zitadel/zitadel/internal/eventstore/handler" + "github.com/zitadel/zitadel/internal/eventstore/handler/v2" + "github.com/zitadel/zitadel/internal/repository/instance" + "github.com/zitadel/zitadel/internal/repository/org" + settings "github.com/zitadel/zitadel/internal/repository/organization_settings" +) + +const ( + OrganizationSettingsTable = "projections.organization_settings" + OrganizationSettingsIDCol = "id" + OrganizationSettingsCreationDateCol = "creation_date" + OrganizationSettingsChangeDateCol = "change_date" + OrganizationSettingsResourceOwnerCol = "resource_owner" + OrganizationSettingsInstanceIDCol = "instance_id" + OrganizationSettingsSequenceCol = "sequence" + OrganizationSettingsOrganizationScopedUsernamesCol = "organization_scoped_usernames" +) + +type organizationSettingsProjection struct{} + +func newOrganizationSettingsProjection(ctx context.Context, config handler.Config) *handler.Handler { + return handler.NewHandler(ctx, &config, new(organizationSettingsProjection)) +} + +func (*organizationSettingsProjection) Name() string { + return OrganizationSettingsTable +} + +func (*organizationSettingsProjection) Init() *old_handler.Check { + return handler.NewTableCheck( + handler.NewTable([]*handler.InitColumn{ + handler.NewColumn(OrganizationSettingsIDCol, handler.ColumnTypeText), + handler.NewColumn(OrganizationSettingsCreationDateCol, handler.ColumnTypeTimestamp), + handler.NewColumn(OrganizationSettingsChangeDateCol, handler.ColumnTypeTimestamp), + handler.NewColumn(OrganizationSettingsResourceOwnerCol, handler.ColumnTypeText), + handler.NewColumn(OrganizationSettingsInstanceIDCol, handler.ColumnTypeText), + handler.NewColumn(OrganizationSettingsSequenceCol, handler.ColumnTypeInt64), + handler.NewColumn(OrganizationSettingsOrganizationScopedUsernamesCol, handler.ColumnTypeBool), + }, + handler.NewPrimaryKey(OrganizationSettingsInstanceIDCol, OrganizationSettingsResourceOwnerCol, OrganizationSettingsIDCol), + handler.WithIndex(handler.NewIndex("resource_owner", []string{OrganizationSettingsResourceOwnerCol})), + ), + ) +} + +func (p *organizationSettingsProjection) Reducers() []handler.AggregateReducer { + return []handler.AggregateReducer{ + { + Aggregate: settings.AggregateType, + EventReducers: []handler.EventReducer{ + { + Event: settings.OrganizationSettingsSetEventType, + Reduce: p.reduceOrganizationSettingsSet, + }, + { + Event: settings.OrganizationSettingsRemovedEventType, + Reduce: p.reduceOrganizationSettingsRemoved, + }, + }, + }, + { + Aggregate: org.AggregateType, + EventReducers: []handler.EventReducer{ + { + Event: org.OrgRemovedEventType, + Reduce: p.reduceOrgRemoved, + }, + }, + }, + { + Aggregate: instance.AggregateType, + EventReducers: []handler.EventReducer{ + { + Event: instance.InstanceRemovedEventType, + Reduce: reduceInstanceRemovedHelper(OrganizationSettingsInstanceIDCol), + }, + }, + }, + } +} + +func (p *organizationSettingsProjection) reduceOrganizationSettingsSet(event eventstore.Event) (*handler.Statement, error) { + e, err := assertEvent[*settings.OrganizationSettingsSetEvent](event) + if err != nil { + return nil, err + } + + return handler.NewUpsertStatement(e, + []handler.Column{ + handler.NewCol(OrganizationSettingsInstanceIDCol, e.Aggregate().InstanceID), + handler.NewCol(OrganizationSettingsResourceOwnerCol, e.Aggregate().ResourceOwner), + handler.NewCol(OrganizationSettingsIDCol, e.Aggregate().ID), + }, + []handler.Column{ + handler.NewCol(OrganizationSettingsInstanceIDCol, e.Aggregate().InstanceID), + handler.NewCol(OrganizationSettingsResourceOwnerCol, e.Aggregate().ResourceOwner), + handler.NewCol(OrganizationSettingsIDCol, e.Aggregate().ID), + handler.NewCol(OrganizationSettingsCreationDateCol, handler.OnlySetValueOnInsert(OrganizationSettingsTable, e.CreationDate())), + handler.NewCol(OrganizationSettingsChangeDateCol, e.CreationDate()), + handler.NewCol(OrganizationSettingsSequenceCol, e.Sequence()), + handler.NewCol(OrganizationSettingsOrganizationScopedUsernamesCol, e.OrganizationScopedUsernames), + }, + ), nil +} + +func (p *organizationSettingsProjection) reduceOrganizationSettingsRemoved(event eventstore.Event) (*handler.Statement, error) { + e, err := assertEvent[*settings.OrganizationSettingsRemovedEvent](event) + if err != nil { + return nil, err + } + + return handler.NewDeleteStatement(e, + []handler.Condition{ + handler.NewCond(OrganizationSettingsInstanceIDCol, e.Aggregate().InstanceID), + handler.NewCond(OrganizationSettingsResourceOwnerCol, e.Aggregate().ResourceOwner), + handler.NewCond(OrganizationSettingsIDCol, e.Aggregate().ID), + }, + ), nil +} + +func (p *organizationSettingsProjection) reduceOrgRemoved(event eventstore.Event) (*handler.Statement, error) { + e, err := assertEvent[*org.OrgRemovedEvent](event) + if err != nil { + return nil, err + } + + return handler.NewDeleteStatement( + e, + []handler.Condition{ + handler.NewCond(OrganizationSettingsInstanceIDCol, e.Aggregate().InstanceID), + handler.NewCond(OrganizationSettingsResourceOwnerCol, e.Aggregate().ResourceOwner), + handler.NewCond(OrganizationSettingsIDCol, e.Aggregate().ID), + }, + ), nil +} diff --git a/internal/query/projection/organization_settings_test.go b/internal/query/projection/organization_settings_test.go new file mode 100644 index 0000000000..e69e42c71e --- /dev/null +++ b/internal/query/projection/organization_settings_test.go @@ -0,0 +1,154 @@ +package projection + +import ( + "testing" + + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/eventstore/handler/v2" + "github.com/zitadel/zitadel/internal/repository/instance" + "github.com/zitadel/zitadel/internal/repository/org" + settings "github.com/zitadel/zitadel/internal/repository/organization_settings" + "github.com/zitadel/zitadel/internal/zerrors" +) + +func TestOrganizationSettingsProjection_reduces(t *testing.T) { + type args struct { + event func(t *testing.T) eventstore.Event + } + tests := []struct { + name string + args args + reduce func(event eventstore.Event) (*handler.Statement, error) + want wantReduce + }{ + { + name: "reduce organization settings set", + args: args{ + event: getEvent( + testEvent( + settings.OrganizationSettingsSetEventType, + settings.AggregateType, + []byte(`{"organizationScopedUsernames": true}`), + ), eventstore.GenericEventMapper[settings.OrganizationSettingsSetEvent], + ), + }, + reduce: (&organizationSettingsProjection{}).reduceOrganizationSettingsSet, + want: wantReduce{ + aggregateType: eventstore.AggregateType("organization_settings"), + sequence: 15, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "INSERT INTO projections.organization_settings (instance_id, resource_owner, id, creation_date, change_date, sequence, organization_scoped_usernames) VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (instance_id, resource_owner, id) DO UPDATE SET (creation_date, change_date, sequence, organization_scoped_usernames) = (projections.organization_settings.creation_date, EXCLUDED.change_date, EXCLUDED.sequence, EXCLUDED.organization_scoped_usernames)", + expectedArgs: []interface{}{ + "instance-id", + "ro-id", + "agg-id", + anyArg{}, + anyArg{}, + uint64(15), + true, + }, + }, + }, + }, + }, + }, + { + name: "reduce organization settings removed", + args: args{ + event: getEvent( + testEvent( + settings.OrganizationSettingsRemovedEventType, + settings.AggregateType, + []byte(`{}`), + ), eventstore.GenericEventMapper[settings.OrganizationSettingsRemovedEvent], + ), + }, + reduce: (&organizationSettingsProjection{}).reduceOrganizationSettingsRemoved, + want: wantReduce{ + aggregateType: eventstore.AggregateType("organization_settings"), + sequence: 15, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "DELETE FROM projections.organization_settings WHERE (instance_id = $1) AND (resource_owner = $2) AND (id = $3)", + expectedArgs: []interface{}{ + "instance-id", + "ro-id", + "agg-id", + }, + }, + }, + }, + }, + }, + { + name: "reduceOrgRemoved", + args: args{ + event: getEvent( + testEvent( + org.OrgRemovedEventType, + org.AggregateType, + nil, + ), org.OrgRemovedEventMapper), + }, + reduce: (&organizationSettingsProjection{}).reduceOrgRemoved, + want: wantReduce{ + aggregateType: eventstore.AggregateType("org"), + sequence: 15, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "DELETE FROM projections.organization_settings WHERE (instance_id = $1) AND (resource_owner = $2) AND (id = $3)", + expectedArgs: []interface{}{ + "instance-id", + "ro-id", + "agg-id", + }, + }, + }, + }, + }, + }, + { + name: "instance reduceInstanceRemoved", + args: args{ + event: getEvent( + testEvent( + instance.InstanceRemovedEventType, + instance.AggregateType, + nil, + ), instance.InstanceRemovedEventMapper), + }, + reduce: reduceInstanceRemovedHelper(OrganizationSettingsInstanceIDCol), + want: wantReduce{ + aggregateType: eventstore.AggregateType("instance"), + sequence: 15, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "DELETE FROM projections.organization_settings WHERE (instance_id = $1)", + expectedArgs: []interface{}{ + "agg-id", + }, + }, + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + event := baseEvent(t) + got, err := tt.reduce(event) + if ok := zerrors.IsErrorInvalidArgument(err); !ok { + t.Errorf("no wrong event mapping: %v, got: %v", err, got) + } + + event = tt.args.event(t) + got, err = tt.reduce(event) + assertReduce(t, got, err, OrganizationSettingsTable, tt.want) + }) + } +} diff --git a/internal/query/projection/projection.go b/internal/query/projection/projection.go index 5ad62380ea..e6f9c64b01 100644 --- a/internal/query/projection/projection.go +++ b/internal/query/projection/projection.go @@ -87,6 +87,7 @@ var ( WebKeyProjection *handler.Handler DebugEventsProjection *handler.Handler HostedLoginTranslationProjection *handler.Handler + OrganizationSettingsProjection *handler.Handler ProjectGrantFields *handler.FieldHandler OrgDomainVerifiedFields *handler.FieldHandler @@ -181,6 +182,7 @@ func Create(ctx context.Context, sqlClient *database.DB, es handler.EventStore, WebKeyProjection = newWebKeyProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["web_keys"])) DebugEventsProjection = newDebugEventsProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["debug_events"])) HostedLoginTranslationProjection = newHostedLoginTranslationProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["hosted_login_translation"])) + OrganizationSettingsProjection = newOrganizationSettingsProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["organization_settings"])) ProjectGrantFields = newFillProjectGrantFields(applyCustomConfig(projectionConfig, config.Customizations[fieldsProjectGrant])) OrgDomainVerifiedFields = newFillOrgDomainVerifiedFields(applyCustomConfig(projectionConfig, config.Customizations[fieldsOrgDomainVerified])) @@ -360,5 +362,6 @@ func newProjectionsList() { WebKeyProjection, DebugEventsProjection, HostedLoginTranslationProjection, + OrganizationSettingsProjection, } } diff --git a/internal/repository/org/org.go b/internal/repository/org/org.go index cf8a3ce114..562cbd85f8 100644 --- a/internal/repository/org/org.go +++ b/internal/repository/org/org.go @@ -275,13 +275,13 @@ func OrgReactivatedEventMapper(event eventstore.Event) (eventstore.Event, error) } type OrgRemovedEvent struct { - eventstore.BaseEvent `json:"-"` - name string - usernames []string - loginMustBeDomain bool - domains []string - externalIDPs []*domain.UserIDPLink - samlEntityIDs []string + eventstore.BaseEvent `json:"-"` + name string + usernames []string + organizationScopedUsernames bool + domains []string + externalIDPs []*domain.UserIDPLink + samlEntityIDs []string } func (e *OrgRemovedEvent) Payload() interface{} { @@ -293,7 +293,7 @@ func (e *OrgRemovedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { NewRemoveOrgNameUniqueConstraint(e.name), } for _, name := range e.usernames { - constraints = append(constraints, user.NewRemoveUsernameUniqueConstraint(name, e.Aggregate().ID, e.loginMustBeDomain)) + constraints = append(constraints, user.NewRemoveUsernameUniqueConstraint(name, e.Aggregate().ID, e.organizationScopedUsernames)) } for _, domain := range e.domains { constraints = append(constraints, NewRemoveOrgDomainUniqueConstraint(domain)) @@ -314,19 +314,19 @@ func (e *OrgRemovedEvent) Fields() []*eventstore.FieldOperation { } } -func NewOrgRemovedEvent(ctx context.Context, aggregate *eventstore.Aggregate, name string, usernames []string, loginMustBeDomain bool, domains []string, externalIDPs []*domain.UserIDPLink, samlEntityIDs []string) *OrgRemovedEvent { +func NewOrgRemovedEvent(ctx context.Context, aggregate *eventstore.Aggregate, name string, usernames []string, organizationScopedUsernames bool, domains []string, externalIDPs []*domain.UserIDPLink, samlEntityIDs []string) *OrgRemovedEvent { return &OrgRemovedEvent{ BaseEvent: *eventstore.NewBaseEventForPush( ctx, aggregate, OrgRemovedEventType, ), - name: name, - usernames: usernames, - domains: domains, - externalIDPs: externalIDPs, - samlEntityIDs: samlEntityIDs, - loginMustBeDomain: loginMustBeDomain, + name: name, + usernames: usernames, + domains: domains, + externalIDPs: externalIDPs, + samlEntityIDs: samlEntityIDs, + organizationScopedUsernames: organizationScopedUsernames, } } diff --git a/internal/repository/organization_settings/aggregate.go b/internal/repository/organization_settings/aggregate.go new file mode 100644 index 0000000000..11ea000785 --- /dev/null +++ b/internal/repository/organization_settings/aggregate.go @@ -0,0 +1,23 @@ +package organization_settings + +import "github.com/zitadel/zitadel/internal/eventstore" + +const ( + AggregateType = "organization_settings" + AggregateVersion = "v1" +) + +type Aggregate struct { + eventstore.Aggregate +} + +func NewAggregate(id, resourceOwner string) *Aggregate { + return &Aggregate{ + Aggregate: eventstore.Aggregate{ + Type: AggregateType, + Version: AggregateVersion, + ID: id, + ResourceOwner: resourceOwner, + }, + } +} diff --git a/internal/repository/organization_settings/eventstore.go b/internal/repository/organization_settings/eventstore.go new file mode 100644 index 0000000000..e3cca585d6 --- /dev/null +++ b/internal/repository/organization_settings/eventstore.go @@ -0,0 +1,8 @@ +package organization_settings + +import "github.com/zitadel/zitadel/internal/eventstore" + +func init() { + eventstore.RegisterFilterEventMapper(AggregateType, OrganizationSettingsSetEventType, eventstore.GenericEventMapper[OrganizationSettingsSetEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, OrganizationSettingsRemovedEventType, eventstore.GenericEventMapper[OrganizationSettingsRemovedEvent]) +} diff --git a/internal/repository/organization_settings/organization.go b/internal/repository/organization_settings/organization.go new file mode 100644 index 0000000000..c157316ba9 --- /dev/null +++ b/internal/repository/organization_settings/organization.go @@ -0,0 +1,96 @@ +package organization_settings + +import ( + "context" + + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/user" +) + +const ( + organizationSettingsPrefix = "settings.organization." + OrganizationSettingsSetEventType = organizationSettingsPrefix + "set" + OrganizationSettingsRemovedEventType = organizationSettingsPrefix + "removed" +) + +type OrganizationSettingsSetEvent struct { + *eventstore.BaseEvent `json:"-"` + + OrganizationScopedUsernames bool `json:"organizationScopedUsernames,omitempty"` + oldOrganizationScopedUsernames bool + usernameChanges []string +} + +func (e *OrganizationSettingsSetEvent) SetBaseEvent(b *eventstore.BaseEvent) { + e.BaseEvent = b +} + +func (e *OrganizationSettingsSetEvent) Payload() any { + return e +} + +func (e *OrganizationSettingsSetEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + if len(e.usernameChanges) == 0 || e.oldOrganizationScopedUsernames == e.OrganizationScopedUsernames { + return []*eventstore.UniqueConstraint{} + } + changes := make([]*eventstore.UniqueConstraint, len(e.usernameChanges)*2) + for i, username := range e.usernameChanges { + changes[i*2] = user.NewRemoveUsernameUniqueConstraint(username, e.Aggregate().ResourceOwner, e.oldOrganizationScopedUsernames) + changes[i*2+1] = user.NewAddUsernameUniqueConstraint(username, e.Aggregate().ResourceOwner, e.OrganizationScopedUsernames) + } + return changes +} + +func NewOrganizationSettingsAddedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, + usernameChanges []string, + organizationScopedUsernames bool, + oldOrganizationScopedUsernames bool, +) *OrganizationSettingsSetEvent { + return &OrganizationSettingsSetEvent{ + BaseEvent: eventstore.NewBaseEventForPush( + ctx, aggregate, OrganizationSettingsSetEventType, + ), + OrganizationScopedUsernames: organizationScopedUsernames, + oldOrganizationScopedUsernames: oldOrganizationScopedUsernames, + usernameChanges: usernameChanges, + } +} + +type OrganizationSettingsRemovedEvent struct { + *eventstore.BaseEvent `json:"-"` + + organizationScopedUsernames bool + oldOrganizationScopedUsernames bool + usernameChanges []string +} + +func (e *OrganizationSettingsRemovedEvent) SetBaseEvent(b *eventstore.BaseEvent) { + e.BaseEvent = b +} + +func (e *OrganizationSettingsRemovedEvent) Payload() any { + return e +} + +func (e *OrganizationSettingsRemovedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + return user.NewUsernameUniqueConstraints(e.usernameChanges, e.Aggregate().ResourceOwner, e.organizationScopedUsernames, e.oldOrganizationScopedUsernames) +} + +func NewOrganizationSettingsRemovedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, + usernameChanges []string, + organizationScopedUsernames bool, + oldOrganizationScopedUsernames bool, +) *OrganizationSettingsRemovedEvent { + return &OrganizationSettingsRemovedEvent{ + BaseEvent: eventstore.NewBaseEventForPush( + ctx, aggregate, OrganizationSettingsRemovedEventType, + ), + organizationScopedUsernames: organizationScopedUsernames, + oldOrganizationScopedUsernames: oldOrganizationScopedUsernames, + usernameChanges: usernameChanges, + } +} diff --git a/internal/repository/policy/policy_domain.go b/internal/repository/policy/policy_domain.go index bd1d9c1b7e..ad459625b2 100644 --- a/internal/repository/policy/policy_domain.go +++ b/internal/repository/policy/policy_domain.go @@ -2,6 +2,7 @@ package policy import ( "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/user" "github.com/zitadel/zitadel/internal/zerrors" ) @@ -122,6 +123,10 @@ func DomainPolicyChangedEventMapper(event eventstore.Event) (eventstore.Event, e type DomainPolicyRemovedEvent struct { eventstore.BaseEvent `json:"-"` + + usernameChanges []string + userLoginMustBeDomain bool + oldUserLoginMustBeDomain bool } func (e *DomainPolicyRemovedEvent) Payload() interface{} { @@ -129,7 +134,7 @@ func (e *DomainPolicyRemovedEvent) Payload() interface{} { } func (e *DomainPolicyRemovedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { - return nil + return user.NewUsernameUniqueConstraints(e.usernameChanges, e.Aggregate().ResourceOwner, e.userLoginMustBeDomain, e.oldUserLoginMustBeDomain) } func NewDomainPolicyRemovedEvent(base *eventstore.BaseEvent) *DomainPolicyRemovedEvent { @@ -143,3 +148,9 @@ func DomainPolicyRemovedEventMapper(event eventstore.Event) (eventstore.Event, e BaseEvent: *eventstore.BaseEventFromRepo(event), }, nil } + +func (e *DomainPolicyRemovedEvent) AddUniqueConstraintChanges(usernameChanges []string, userLoginMustBeDomain, oldUserLoginMustBeDomain bool) { + e.usernameChanges = usernameChanges + e.userLoginMustBeDomain = userLoginMustBeDomain + e.oldUserLoginMustBeDomain = oldUserLoginMustBeDomain +} diff --git a/internal/repository/user/human.go b/internal/repository/user/human.go index d503ecc899..823a86aaed 100644 --- a/internal/repository/user/human.go +++ b/internal/repository/user/human.go @@ -31,8 +31,8 @@ const ( type HumanAddedEvent struct { eventstore.BaseEvent `json:"-"` - UserName string `json:"userName"` - userLoginMustBeDomain bool + UserName string `json:"userName"` + orgScopedUsername bool FirstName string `json:"firstName,omitempty"` LastName string `json:"lastName,omitempty"` @@ -63,7 +63,7 @@ func (e *HumanAddedEvent) Payload() interface{} { } func (e *HumanAddedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { - return []*eventstore.UniqueConstraint{NewAddUsernameUniqueConstraint(e.UserName, e.Aggregate().ResourceOwner, e.userLoginMustBeDomain)} + return []*eventstore.UniqueConstraint{NewAddUsernameUniqueConstraint(e.UserName, e.Aggregate().ResourceOwner, e.orgScopedUsername)} } func (e *HumanAddedEvent) AddAddressData( @@ -106,7 +106,7 @@ func NewHumanAddedEvent( preferredLanguage language.Tag, gender domain.Gender, emailAddress domain.EmailAddress, - userLoginMustBeDomain bool, + orgScopedUsername bool, ) *HumanAddedEvent { return &HumanAddedEvent{ BaseEvent: *eventstore.NewBaseEventForPush( @@ -114,15 +114,15 @@ func NewHumanAddedEvent( aggregate, HumanAddedType, ), - UserName: userName, - FirstName: firstName, - LastName: lastName, - NickName: nickName, - DisplayName: displayName, - PreferredLanguage: preferredLanguage, - Gender: gender, - EmailAddress: emailAddress, - userLoginMustBeDomain: userLoginMustBeDomain, + UserName: userName, + FirstName: firstName, + LastName: lastName, + NickName: nickName, + DisplayName: displayName, + PreferredLanguage: preferredLanguage, + Gender: gender, + EmailAddress: emailAddress, + orgScopedUsername: orgScopedUsername, } } @@ -139,22 +139,24 @@ func HumanAddedEventMapper(event eventstore.Event) (eventstore.Event, error) { } type HumanRegisteredEvent struct { - eventstore.BaseEvent `json:"-"` - UserName string `json:"userName"` - userLoginMustBeDomain bool - FirstName string `json:"firstName,omitempty"` - LastName string `json:"lastName,omitempty"` - NickName string `json:"nickName,omitempty"` - DisplayName string `json:"displayName,omitempty"` - PreferredLanguage language.Tag `json:"preferredLanguage,omitempty"` - Gender domain.Gender `json:"gender,omitempty"` - EmailAddress domain.EmailAddress `json:"email,omitempty"` - PhoneNumber domain.PhoneNumber `json:"phone,omitempty"` - Country string `json:"country,omitempty"` - Locality string `json:"locality,omitempty"` - PostalCode string `json:"postalCode,omitempty"` - Region string `json:"region,omitempty"` - StreetAddress string `json:"streetAddress,omitempty"` + eventstore.BaseEvent `json:"-"` + + UserName string `json:"userName"` + orgScopedUsername bool + + FirstName string `json:"firstName,omitempty"` + LastName string `json:"lastName,omitempty"` + NickName string `json:"nickName,omitempty"` + DisplayName string `json:"displayName,omitempty"` + PreferredLanguage language.Tag `json:"preferredLanguage,omitempty"` + Gender domain.Gender `json:"gender,omitempty"` + EmailAddress domain.EmailAddress `json:"email,omitempty"` + PhoneNumber domain.PhoneNumber `json:"phone,omitempty"` + Country string `json:"country,omitempty"` + Locality string `json:"locality,omitempty"` + PostalCode string `json:"postalCode,omitempty"` + Region string `json:"region,omitempty"` + StreetAddress string `json:"streetAddress,omitempty"` // New events only use EncodedHash. However, the secret field // is preserved to handle events older than the switch to Passwap. @@ -170,7 +172,7 @@ func (e *HumanRegisteredEvent) Payload() interface{} { } func (e *HumanRegisteredEvent) UniqueConstraints() []*eventstore.UniqueConstraint { - return []*eventstore.UniqueConstraint{NewAddUsernameUniqueConstraint(e.UserName, e.Aggregate().ResourceOwner, e.userLoginMustBeDomain)} + return []*eventstore.UniqueConstraint{NewAddUsernameUniqueConstraint(e.UserName, e.Aggregate().ResourceOwner, e.orgScopedUsername)} } func (e *HumanRegisteredEvent) AddAddressData( @@ -213,7 +215,7 @@ func NewHumanRegisteredEvent( preferredLanguage language.Tag, gender domain.Gender, emailAddress domain.EmailAddress, - userLoginMustBeDomain bool, + orgScopedUsername bool, userAgentID string, ) *HumanRegisteredEvent { return &HumanRegisteredEvent{ @@ -222,16 +224,16 @@ func NewHumanRegisteredEvent( aggregate, HumanRegisteredType, ), - UserName: userName, - FirstName: firstName, - LastName: lastName, - NickName: nickName, - DisplayName: displayName, - PreferredLanguage: preferredLanguage, - Gender: gender, - EmailAddress: emailAddress, - userLoginMustBeDomain: userLoginMustBeDomain, - UserAgentID: userAgentID, + UserName: userName, + FirstName: firstName, + LastName: lastName, + NickName: nickName, + DisplayName: displayName, + PreferredLanguage: preferredLanguage, + Gender: gender, + EmailAddress: emailAddress, + orgScopedUsername: orgScopedUsername, + UserAgentID: userAgentID, } } diff --git a/internal/repository/user/machine.go b/internal/repository/user/machine.go index a466f92fe3..b54149639a 100644 --- a/internal/repository/user/machine.go +++ b/internal/repository/user/machine.go @@ -17,8 +17,8 @@ const ( type MachineAddedEvent struct { eventstore.BaseEvent `json:"-"` - UserName string `json:"userName"` - userLoginMustBeDomain bool + UserName string `json:"userName"` + orgScopedUsername bool Name string `json:"name,omitempty"` Description string `json:"description,omitempty"` @@ -30,7 +30,7 @@ func (e *MachineAddedEvent) Payload() interface{} { } func (e *MachineAddedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { - return []*eventstore.UniqueConstraint{NewAddUsernameUniqueConstraint(e.UserName, e.Aggregate().ResourceOwner, e.userLoginMustBeDomain)} + return []*eventstore.UniqueConstraint{NewAddUsernameUniqueConstraint(e.UserName, e.Aggregate().ResourceOwner, e.orgScopedUsername)} } func NewMachineAddedEvent( @@ -39,7 +39,7 @@ func NewMachineAddedEvent( userName, name, description string, - userLoginMustBeDomain bool, + orgScopedUsername bool, accessTokenType domain.OIDCTokenType, ) *MachineAddedEvent { return &MachineAddedEvent{ @@ -48,11 +48,11 @@ func NewMachineAddedEvent( aggregate, MachineAddedEventType, ), - UserName: userName, - Name: name, - Description: description, - userLoginMustBeDomain: userLoginMustBeDomain, - AccessTokenType: accessTokenType, + UserName: userName, + Name: name, + Description: description, + orgScopedUsername: orgScopedUsername, + AccessTokenType: accessTokenType, } } diff --git a/internal/repository/user/user.go b/internal/repository/user/user.go index e8faddb645..470edc6f16 100644 --- a/internal/repository/user/user.go +++ b/internal/repository/user/user.go @@ -27,9 +27,9 @@ const ( UserUserNameChangedType = userEventTypePrefix + "username.changed" ) -func NewAddUsernameUniqueConstraint(userName, resourceOwner string, userLoginMustBeDomain bool) *eventstore.UniqueConstraint { +func NewAddUsernameUniqueConstraint(userName, resourceOwner string, orgScopedUsername bool) *eventstore.UniqueConstraint { uniqueUserName := userName - if userLoginMustBeDomain { + if orgScopedUsername { uniqueUserName = userName + resourceOwner } return eventstore.NewAddEventUniqueConstraint( @@ -38,9 +38,9 @@ func NewAddUsernameUniqueConstraint(userName, resourceOwner string, userLoginMus "Errors.User.AlreadyExists") } -func NewRemoveUsernameUniqueConstraint(userName, resourceOwner string, userLoginMustBeDomain bool) *eventstore.UniqueConstraint { +func NewRemoveUsernameUniqueConstraint(userName, resourceOwner string, orgScopedUsername bool) *eventstore.UniqueConstraint { uniqueUserName := userName - if userLoginMustBeDomain { + if orgScopedUsername { uniqueUserName = userName + resourceOwner } return eventstore.NewRemoveUniqueConstraint( @@ -48,6 +48,18 @@ func NewRemoveUsernameUniqueConstraint(userName, resourceOwner string, userLogin uniqueUserName) } +func NewUsernameUniqueConstraints(usernameChanges []string, resourceOwner string, orgScopedUsername, oldOrgScopedUsername bool) []*eventstore.UniqueConstraint { + if len(usernameChanges) == 0 || oldOrgScopedUsername == orgScopedUsername { + return []*eventstore.UniqueConstraint{} + } + changes := make([]*eventstore.UniqueConstraint, len(usernameChanges)*2) + for i, username := range usernameChanges { + changes[i*2] = NewRemoveUsernameUniqueConstraint(username, resourceOwner, oldOrgScopedUsername) + changes[i*2+1] = NewAddUsernameUniqueConstraint(username, resourceOwner, orgScopedUsername) + } + return changes +} + type UserLockedEvent struct { eventstore.BaseEvent `json:"-"` } @@ -165,7 +177,7 @@ type UserRemovedEvent struct { userName string externalIDPs []*domain.UserIDPLink - loginMustBeDomain bool + orgScopedUsername bool } func (e *UserRemovedEvent) Payload() interface{} { @@ -175,7 +187,7 @@ func (e *UserRemovedEvent) Payload() interface{} { func (e *UserRemovedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { events := make([]*eventstore.UniqueConstraint, 0) if e.userName != "" { - events = append(events, NewRemoveUsernameUniqueConstraint(e.userName, e.Aggregate().ResourceOwner, e.loginMustBeDomain)) + events = append(events, NewRemoveUsernameUniqueConstraint(e.userName, e.Aggregate().ResourceOwner, e.orgScopedUsername)) } for _, idp := range e.externalIDPs { events = append(events, NewRemoveUserIDPLinkUniqueConstraint(idp.IDPConfigID, idp.ExternalUserID)) @@ -188,7 +200,7 @@ func NewUserRemovedEvent( aggregate *eventstore.Aggregate, userName string, externalIDPs []*domain.UserIDPLink, - userLoginMustBeDomain bool, + orgScopedUsername bool, ) *UserRemovedEvent { return &UserRemovedEvent{ BaseEvent: *eventstore.NewBaseEventForPush( @@ -198,7 +210,7 @@ func NewUserRemovedEvent( ), userName: userName, externalIDPs: externalIDPs, - loginMustBeDomain: userLoginMustBeDomain, + orgScopedUsername: orgScopedUsername, } } @@ -393,10 +405,10 @@ func UserTokenRemovedEventMapper(event eventstore.Event) (eventstore.Event, erro type DomainClaimedEvent struct { eventstore.BaseEvent `json:"-"` - UserName string `json:"userName"` - TriggeredAtOrigin string `json:"triggerOrigin,omitempty"` - oldUserName string - userLoginMustBeDomain bool + UserName string `json:"userName"` + TriggeredAtOrigin string `json:"triggerOrigin,omitempty"` + oldUserName string + orgScopedUsername bool } func (e *DomainClaimedEvent) Payload() interface{} { @@ -405,8 +417,8 @@ func (e *DomainClaimedEvent) Payload() interface{} { func (e *DomainClaimedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { return []*eventstore.UniqueConstraint{ - NewRemoveUsernameUniqueConstraint(e.oldUserName, e.Aggregate().ResourceOwner, e.userLoginMustBeDomain), - NewAddUsernameUniqueConstraint(e.UserName, e.Aggregate().ResourceOwner, e.userLoginMustBeDomain), + NewRemoveUsernameUniqueConstraint(e.oldUserName, e.Aggregate().ResourceOwner, e.orgScopedUsername), + NewAddUsernameUniqueConstraint(e.UserName, e.Aggregate().ResourceOwner, e.orgScopedUsername), } } @@ -419,7 +431,7 @@ func NewDomainClaimedEvent( aggregate *eventstore.Aggregate, userName, oldUserName string, - userLoginMustBeDomain bool, + orgScopedUsername bool, ) *DomainClaimedEvent { return &DomainClaimedEvent{ BaseEvent: *eventstore.NewBaseEventForPush( @@ -427,10 +439,10 @@ func NewDomainClaimedEvent( aggregate, UserDomainClaimedType, ), - UserName: userName, - oldUserName: oldUserName, - userLoginMustBeDomain: userLoginMustBeDomain, - TriggeredAtOrigin: http.DomainContext(ctx).Origin(), + UserName: userName, + oldUserName: oldUserName, + orgScopedUsername: orgScopedUsername, + TriggeredAtOrigin: http.DomainContext(ctx).Origin(), } } @@ -480,10 +492,11 @@ func DomainClaimedSentEventMapper(event eventstore.Event) (eventstore.Event, err type UsernameChangedEvent struct { eventstore.BaseEvent `json:"-"` - UserName string `json:"userName"` - oldUserName string - userLoginMustBeDomain bool - oldUserLoginMustBeDomain bool + UserName string `json:"userName"` + oldUserName string + userLoginMustBeDomain bool + oldUserLoginMustBeDomain bool + organizationScopedUsernames bool } func (e *UsernameChangedEvent) Payload() interface{} { @@ -491,9 +504,20 @@ func (e *UsernameChangedEvent) Payload() interface{} { } func (e *UsernameChangedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + newSetting := e.userLoginMustBeDomain || e.organizationScopedUsernames + oldSetting := e.oldUserLoginMustBeDomain || e.organizationScopedUsernames + + // changes only necessary if username changed or setting for usernames changed + // if user login must be domain is set, there is a possibility that the username changes + // organization scoped usernames are included here so that the unique constraint only gets changed if necessary + if e.oldUserName == e.UserName && + newSetting == oldSetting { + return []*eventstore.UniqueConstraint{} + } + return []*eventstore.UniqueConstraint{ - NewRemoveUsernameUniqueConstraint(e.oldUserName, e.Aggregate().ResourceOwner, e.oldUserLoginMustBeDomain), - NewAddUsernameUniqueConstraint(e.UserName, e.Aggregate().ResourceOwner, e.userLoginMustBeDomain), + NewRemoveUsernameUniqueConstraint(e.oldUserName, e.Aggregate().ResourceOwner, oldSetting), + NewAddUsernameUniqueConstraint(e.UserName, e.Aggregate().ResourceOwner, newSetting), } } @@ -503,6 +527,7 @@ func NewUsernameChangedEvent( oldUserName, newUserName string, userLoginMustBeDomain bool, + organizationScopedUsernames bool, opts ...UsernameChangedEventOption, ) *UsernameChangedEvent { event := &UsernameChangedEvent{ @@ -511,10 +536,11 @@ func NewUsernameChangedEvent( aggregate, UserUserNameChangedType, ), - UserName: newUserName, - oldUserName: oldUserName, - userLoginMustBeDomain: userLoginMustBeDomain, - oldUserLoginMustBeDomain: userLoginMustBeDomain, + UserName: newUserName, + oldUserName: oldUserName, + userLoginMustBeDomain: userLoginMustBeDomain, + oldUserLoginMustBeDomain: userLoginMustBeDomain, + organizationScopedUsernames: organizationScopedUsernames, } for _, opt := range opts { opt(event) @@ -526,9 +552,9 @@ type UsernameChangedEventOption func(*UsernameChangedEvent) // UsernameChangedEventWithPolicyChange signals that the change occurs because of / during a domain policy change // (will ensure the unique constraint change is handled correctly) -func UsernameChangedEventWithPolicyChange() UsernameChangedEventOption { +func UsernameChangedEventWithPolicyChange(oldUserLoginMustBeDomain bool) UsernameChangedEventOption { return func(e *UsernameChangedEvent) { - e.oldUserLoginMustBeDomain = !e.userLoginMustBeDomain + e.oldUserLoginMustBeDomain = oldUserLoginMustBeDomain } } diff --git a/proto/zitadel/filter/v2/filter.proto b/proto/zitadel/filter/v2/filter.proto index 3817324d31..ec85519b44 100644 --- a/proto/zitadel/filter/v2/filter.proto +++ b/proto/zitadel/filter/v2/filter.proto @@ -94,3 +94,23 @@ message TimestampFilter { (validate.rules).enum.defined_only = true ]; } + +message InIDsFilter { + // Defines the ids to query for. + repeated string ids = 1 [ + (validate.rules).repeated = { + unique: true + items: { + string: { + min_len: 1 + max_len: 200 + } + } + }, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "[\"69629023906488334\",\"69622366012355662\"]"; + } + ]; +} diff --git a/proto/zitadel/project/v2beta/project_service.proto b/proto/zitadel/project/v2beta/project_service.proto index 66f221b911..3fd1513d2d 100644 --- a/proto/zitadel/project/v2beta/project_service.proto +++ b/proto/zitadel/project/v2beta/project_service.proto @@ -638,7 +638,7 @@ service ProjectService { // Returns a list of project grants. A project grant is when the organization grants its project to another organization. // // Required permission: - // - `project.grant.write` + // - `project.grant.read` rpc ListProjectGrants(ListProjectGrantsRequest) returns (ListProjectGrantsResponse) { option (google.api.http) = { post: "/v2beta/projects/grants/search" diff --git a/proto/zitadel/settings/v2/settings_service.proto b/proto/zitadel/settings/v2/settings_service.proto index 0a1f13e7e7..ea3e2c3653 100644 --- a/proto/zitadel/settings/v2/settings_service.proto +++ b/proto/zitadel/settings/v2/settings_service.proto @@ -17,6 +17,8 @@ import "protoc-gen-openapiv2/options/annotations.proto"; import "validate/validate.proto"; import "google/protobuf/struct.proto"; import "zitadel/settings/v2/settings.proto"; +import "google/protobuf/timestamp.proto"; +import "zitadel/filter/v2/filter.proto"; option go_package = "github.com/zitadel/zitadel/pkg/grpc/settings/v2;settings"; @@ -415,7 +417,7 @@ service SettingsService { } }; }; - + option (google.api.http) = { put: "/v2/settings/hosted_login_translation"; body: "*" @@ -579,8 +581,8 @@ message GetHostedLoginTranslationResponse { google.protobuf.Struct translations = 2 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - example: "{\"common\":{\"back\":\"Indietro\"},\"accounts\":{\"title\":\"Account\",\"description\":\"Seleziona l'account che desideri utilizzare.\",\"addAnother\":\"Aggiungi un altro account\",\"noResults\":\"Nessun account trovato\"}}"; - description: "Translations contains the translations in the request language."; + example: "{\"common\":{\"back\":\"Indietro\"},\"accounts\":{\"title\":\"Account\",\"description\":\"Seleziona l'account che desideri utilizzare.\",\"addAnother\":\"Aggiungi un altro account\",\"noResults\":\"Nessun account trovato\"}}"; + description: "Translations contains the translations in the request language."; } ]; } @@ -590,7 +592,7 @@ message SetHostedLoginTranslationRequest { bool instance = 1 [(validate.rules).bool = {const: true}]; string organization_id = 2; } - + string locale = 3 [ (validate.rules).string = {min_len: 2}, (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -601,8 +603,8 @@ message SetHostedLoginTranslationRequest { google.protobuf.Struct translations = 4 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - example: "{\"common\":{\"back\":\"Indietro\"},\"accounts\":{\"title\":\"Account\",\"description\":\"Seleziona l'account che desideri utilizzare.\",\"addAnother\":\"Aggiungi un altro account\",\"noResults\":\"Nessun account trovato\"}}"; - description: "Translations should contain the translations in the specified locale."; + example: "{\"common\":{\"back\":\"Indietro\"},\"accounts\":{\"title\":\"Account\",\"description\":\"Seleziona l'account che desideri utilizzare.\",\"addAnother\":\"Aggiungi un altro account\",\"noResults\":\"Nessun account trovato\"}}"; + description: "Translations should contain the translations in the specified locale."; } ]; } @@ -617,4 +619,4 @@ message SetHostedLoginTranslationResponse { example: "\"42a1ba123e6ea6f0c93e286ed97c7018\""; } ]; -} \ No newline at end of file +} diff --git a/proto/zitadel/settings/v2beta/organization_settings.proto b/proto/zitadel/settings/v2beta/organization_settings.proto new file mode 100644 index 0000000000..743dbf9881 --- /dev/null +++ b/proto/zitadel/settings/v2beta/organization_settings.proto @@ -0,0 +1,55 @@ +syntax = "proto3"; + +package zitadel.settings.v2beta; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/settings/v2beta;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/v2beta/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.v2beta.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/v2beta/settings_service.proto b/proto/zitadel/settings/v2beta/settings_service.proto index 9404e002a7..a1679b7cb7 100644 --- a/proto/zitadel/settings/v2beta/settings_service.proto +++ b/proto/zitadel/settings/v2beta/settings_service.proto @@ -15,6 +15,9 @@ import "google/api/annotations.proto"; 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/v2beta/filter.proto"; +import "zitadel/settings/v2beta/organization_settings.proto"; option go_package = "github.com/zitadel/zitadel/pkg/grpc/settings/v2beta;settings"; @@ -360,6 +363,98 @@ service SettingsService { description: "Set the security settings of the ZITADEL instance." }; } + + // 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"; + }; + }; + }; + } } message GetLoginSettingsRequest { @@ -474,4 +569,55 @@ message SetSecuritySettingsRequest{ message SetSecuritySettingsResponse{ zitadel.object.v2beta.Details details = 1; -} \ No newline at end of file +} + + +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.v2beta.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\"" + } + ]; + 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.v2beta.PaginationResponse pagination = 1; + repeated OrganizationSettings organization_settings = 2; +} From d92dd91b487f9558f5a462a8c521ce997d554110 Mon Sep 17 00:00:00 2001 From: Elio Bischof Date: Wed, 30 Jul 2025 10:22:05 +0200 Subject: [PATCH 02/16] fix: fix login image --- apps/login/scripts/entrypoint.sh | 4 +--- .../{.dockerignore => Dockerfile.dockerignore} | 13 +++++++------ 2 files changed, 8 insertions(+), 9 deletions(-) rename build/login/{.dockerignore => Dockerfile.dockerignore} (80%) diff --git a/apps/login/scripts/entrypoint.sh b/apps/login/scripts/entrypoint.sh index 123612d1cb..5bafc0012d 100755 --- a/apps/login/scripts/entrypoint.sh +++ b/apps/login/scripts/entrypoint.sh @@ -8,6 +8,4 @@ if [ -n "${ZITADEL_SERVICE_USER_TOKEN_FILE}" ] && [ -f "${ZITADEL_SERVICE_USER_T export ZITADEL_SERVICE_USER_TOKEN=$(cat "${ZITADEL_SERVICE_USER_TOKEN_FILE}") fi - - -exec node /runtime/apps/login/apps/login/server.js +exec node /runtime/apps/login/server.js diff --git a/build/login/.dockerignore b/build/login/Dockerfile.dockerignore similarity index 80% rename from build/login/.dockerignore rename to build/login/Dockerfile.dockerignore index 2070cf5982..c2a0ee4a18 100644 --- a/build/login/.dockerignore +++ b/build/login/Dockerfile.dockerignore @@ -8,7 +8,8 @@ !apps/login/next.config.mjs !apps/login/next-env-vars.d.ts !apps/login/next-env.d.ts -!apps/login/tailwind.config.js +!apps/login/tailwind.config.mjs +!apps/login/postcss.config.cjs !apps/login/tsconfig.json !apps/login/package.json !apps/login/turbo.json @@ -23,6 +24,7 @@ !packages/zitadel-proto/turbo.json !packages/zitadel-client/package.json +!packages/zitadel-client/**/package.json !packages/zitadel-client/src !packages/zitadel-client/tsconfig.json !packages/zitadel-client/tsup.config.ts @@ -30,8 +32,7 @@ !proto -*.md -*.png -node_modules -*.test.ts -*.test.tsx +**/*.md +**/node_modules +**/*.test.ts +**/*.test.tsx From b1907022432d532b4b6628316de880beb9c87085 Mon Sep 17 00:00:00 2001 From: Elio Bischof Date: Wed, 30 Jul 2025 10:24:16 +0200 Subject: [PATCH 03/16] build locally --- .github/workflows/login-container.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/login-container.yml b/.github/workflows/login-container.yml index 4e78ba68cd..e9a0b97648 100644 --- a/.github/workflows/login-container.yml +++ b/.github/workflows/login-container.yml @@ -55,6 +55,7 @@ jobs: env: NODE_VERSION: ${{ inputs.node_version }} with: + source: . push: true provenance: true sbom: true From 50f92afb6973b29248b3dfebc528ebfdc41aa657 Mon Sep 17 00:00:00 2001 From: Maximilian Date: Wed, 30 Jul 2025 12:28:51 +0200 Subject: [PATCH 04/16] docs(config): Add mermaid diagram support (#10357) # Which Problems Are Solved #7573 # How the Problems Are Solved Enabled mermaid support in the current version: https://docusaurus.io/docs/next/markdown-features/diagrams # Additional Changes # Additional Context test by adding to a page: ```mermaid graph TD; A-->B; A-->C; B-->D; C-->D; ``` --- docs/docusaurus.config.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js index 0827c0f75b..0af3063972 100644 --- a/docs/docusaurus.config.js +++ b/docs/docusaurus.config.js @@ -23,6 +23,7 @@ module.exports = { description: "Documentation for ZITADEL - Identity infrastructure, simplified for you.", }, + themeConfig: { metadata: [ { @@ -450,9 +451,13 @@ module.exports = { }; }, ], + markdown: { + mermaid:true, + }, themes: [ "docusaurus-theme-github-codeblock", "docusaurus-theme-openapi-docs", + '@docusaurus/theme-mermaid', ], future: { v4: false, // Disabled because of some problems related to https://github.com/facebook/docusaurus/issues/11040 From 6a7cd5b51d63a0153be163d8630c821af06b7f23 Mon Sep 17 00:00:00 2001 From: Elio Bischof Date: Wed, 30 Jul 2025 12:34:58 +0200 Subject: [PATCH 05/16] fix static path --- build/login/Dockerfile | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/build/login/Dockerfile b/build/login/Dockerfile index ad9163285f..cd75728738 100644 --- a/build/login/Dockerfile +++ b/build/login/Dockerfile @@ -24,9 +24,9 @@ COPY . . RUN pnpm turbo build:login:standalone FROM scratch AS build-out -COPY --from=build /app/apps/login/.next/standalone / -COPY --from=build /app/apps/login/.next/static /.next/static -COPY --from=build /app/apps/login/public /public +COPY /apps/login/public ./apps/login/public +COPY --from=build /app/apps/login/.next/standalone ./ +COPY --from=build /app/apps/login/.next/static ./apps/login/.next/static FROM base AS login-standalone WORKDIR /runtime @@ -34,7 +34,7 @@ RUN addgroup --system --gid 1001 nodejs && \ adduser --system --uid 1001 nextjs # If /.env-file/.env is mounted into the container, its variables are made available to the server before it starts up. RUN mkdir -p /.env-file && touch /.env-file/.env && chown -R nextjs:nodejs /.env-file -COPY apps/login/scripts ./ +COPY --chown=nextjs:nodejs apps/login/scripts ./ COPY --chown=nextjs:nodejs --from=build-out . . USER nextjs ENV HOSTNAME="0.0.0.0" From c41db3f14294328d8489c21b8a6afe0489f10f7f Mon Sep 17 00:00:00 2001 From: Elio Bischof Date: Wed, 30 Jul 2025 12:58:01 +0200 Subject: [PATCH 06/16] bake login after zitadel compile --- .github/workflows/build.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b805c99060..c2a9ef222c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -94,6 +94,7 @@ jobs: build_image_name: "ghcr.io/zitadel/zitadel-build" login-container: + needs: [compile] uses: ./.github/workflows/login-container.yml permissions: packages: write From e27e27a7724abaf37fd59c969b0ee3c4444e5050 Mon Sep 17 00:00:00 2001 From: Elio Bischof Date: Wed, 30 Jul 2025 13:00:41 +0200 Subject: [PATCH 07/16] omit .dockerbuild artifact --- .github/workflows/compile.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/compile.yml b/.github/workflows/compile.yml index e1493cfcff..42efd9b873 100644 --- a/.github/workflows/compile.yml +++ b/.github/workflows/compile.yml @@ -77,6 +77,7 @@ jobs: - uses: actions/download-artifact@v4 with: path: executables + pattern: 'zitadel-*-*.tar.gz' - name: move files one folder up run: mv */*.tar.gz . && find . -type d -empty -delete working-directory: executables From 838c3fbe9c5d28de0d2112c97f60035f8ffe1737 Mon Sep 17 00:00:00 2001 From: Elio Bischof Date: Wed, 30 Jul 2025 13:05:55 +0200 Subject: [PATCH 08/16] omit .dockerbuild artifact --- .github/workflows/compile.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/compile.yml b/.github/workflows/compile.yml index 42efd9b873..0c36624a46 100644 --- a/.github/workflows/compile.yml +++ b/.github/workflows/compile.yml @@ -77,7 +77,7 @@ jobs: - uses: actions/download-artifact@v4 with: path: executables - pattern: 'zitadel-*-*.tar.gz' + pattern: 'zitadel-*-*' - name: move files one folder up run: mv */*.tar.gz . && find . -type d -empty -delete working-directory: executables From 9f03a827a3fb0ebec6f1a8815a3841868d36eef3 Mon Sep 17 00:00:00 2001 From: Elio Bischof Date: Wed, 30 Jul 2025 13:27:13 +0200 Subject: [PATCH 09/16] cleanup --- .github/workflows/build.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c2a9ef222c..b805c99060 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -94,7 +94,6 @@ jobs: build_image_name: "ghcr.io/zitadel/zitadel-build" login-container: - needs: [compile] uses: ./.github/workflows/login-container.yml permissions: packages: write From 82e4466928b9c3e5a5246cef1c9221a4fc8e4a19 Mon Sep 17 00:00:00 2001 From: elinashoko Date: Wed, 30 Jul 2025 18:41:25 +0700 Subject: [PATCH 10/16] docs: deprecate organization v2beta endpoints + remove scim preview notice (#10350) # Which Problems Are Solved The documentation API regarding organization v2 beta is not up to date. The documentation regarding SCIM v2 is not up to date. # How the Problems Are Solved - Deprecate the existing v2beta endpoints `CreateOrganization` and `ListOrganizations` in favour of the v2 counterparst - Remove the preview warning from SCIM v2 pages # Additional Context - Closes #10311 and #10310 --------- Co-authored-by: Marco Ardizzone --- docs/docs/apis/scim2.md | 9 --------- docs/docs/guides/manage/user/scim2.md | 9 --------- docs/sidebars.js | 4 ++-- proto/zitadel/org/v2beta/org_service.proto | 8 +++++++- 4 files changed, 9 insertions(+), 21 deletions(-) diff --git a/docs/docs/apis/scim2.md b/docs/docs/apis/scim2.md index 10afbb2c5c..d342142cf0 100644 --- a/docs/docs/apis/scim2.md +++ b/docs/docs/apis/scim2.md @@ -2,15 +2,6 @@ title: SCIM v2.0 (Preview) --- -:::info -The SCIM v2 interface of Zitadel is currently in a [preview stage](/support/software-release-cycles-support#preview). -It is not yet feature-complete, may contain bugs, and is not generally available. - -Do not use it for production yet. - -As long as the feature is in a preview state, it will be available for free, it will be put behind a commercial license once it is fully available. -::: - The Zitadel [SCIM v2](https://scim.cloud/) service provider interface enables seamless integration of identity and access management (IAM) systems with Zitadel, following the System for Cross-domain Identity Management (SCIM) v2.0 specification. diff --git a/docs/docs/guides/manage/user/scim2.md b/docs/docs/guides/manage/user/scim2.md index edf5e7bd10..2d9b90c681 100644 --- a/docs/docs/guides/manage/user/scim2.md +++ b/docs/docs/guides/manage/user/scim2.md @@ -2,15 +2,6 @@ title: SCIM v2.0 (Preview) --- -:::info -The SCIM v2 interface of Zitadel is currently in a [preview stage](/support/software-release-cycles-support#preview). -It is not yet feature-complete, may contain bugs, and is not generally available. - -Do not use it for production yet. - -As long as the feature is in a preview state, it will be available for free, it will be put behind a commercial license once it is fully available. -::: - The Zitadel [SCIM v2](https://scim.cloud/) service provider interface enables seamless integration of identity and access management (IAM) systems with Zitadel, following the System for Cross-domain Identity Management (SCIM) v2.0 specification. diff --git a/docs/sidebars.js b/docs/sidebars.js index 933b0a3bfe..578a337da9 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -751,10 +751,10 @@ module.exports = { label: "Organization (Beta)", link: { type: "generated-index", - title: "Organization Service beta API", + title: "Organization Service Beta API", slug: "/apis/resources/org_service/v2beta", description: - "This API is intended to manage organizations for ZITADEL. \n", + "This beta API is intended to manage organizations for ZITADEL. Expect breaking changes to occur. Please use the v2 version for a stable API. \n", }, items: sidebar_api_org_service_v2beta, }, diff --git a/proto/zitadel/org/v2beta/org_service.proto b/proto/zitadel/org/v2beta/org_service.proto index 387b2cb825..43307214f5 100644 --- a/proto/zitadel/org/v2beta/org_service.proto +++ b/proto/zitadel/org/v2beta/org_service.proto @@ -115,6 +115,8 @@ service OrganizationService { // // Required permission: // - `org.create` + // + // Deprecated: Use [AddOrganization](/apis/resources/org_service_v2/organization-service-add-organization.api.mdx) instead to create an organization. rpc CreateOrganization(CreateOrganizationRequest) returns (CreateOrganizationResponse) { option (google.api.http) = { post: "/v2beta/organizations" @@ -127,7 +129,7 @@ service OrganizationService { } }; - option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { responses: { key: "200"; value: { @@ -140,6 +142,7 @@ service OrganizationService { description: "The organization to create already exists."; } }; + deprecated: true; }; } @@ -190,6 +193,8 @@ service OrganizationService { // // Required permission: // - `iam.read` + // + // Deprecated: Use [ListOrganizations](/apis/resources/org_service_v2/organization-service-list-organizations.api.mdx) instead to list organizations. rpc ListOrganizations(ListOrganizationsRequest) returns (ListOrganizationsResponse) { option (google.api.http) = { post: "/v2beta/organizations/search"; @@ -206,6 +211,7 @@ service OrganizationService { responses: { key: "200"; }; + deprecated: true; }; } From 6f909c07a48523b59d925e2acfa2e11fe91a31bd Mon Sep 17 00:00:00 2001 From: Elio Bischof Date: Wed, 30 Jul 2025 18:18:07 +0200 Subject: [PATCH 11/16] chore(e2e): install firefox --- .github/workflows/e2e.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index b9c2159e1c..9fab716daa 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -33,7 +33,7 @@ jobs: cache: "pnpm" cache-dependency-path: pnpm-lock.yaml - name: Install dependencies - run: pnpm install + run: pnpm install --filter e2e --frozen-lockfile - name: Install Cypress binary run: cd ./e2e && pnpm exec cypress install - name: Start DB and ZITADEL @@ -51,7 +51,7 @@ jobs: working-directory: e2e browser: ${{ matrix.browser }} config-file: cypress.config.ts - install: false + install: ${{ matrix.browser == 'firefox' }} - uses: actions/upload-artifact@v4 if: always() with: From af18424cfd8efc286eca112902e56c10bc1970f5 Mon Sep 17 00:00:00 2001 From: Elio Bischof Date: Wed, 30 Jul 2025 19:05:16 +0200 Subject: [PATCH 12/16] chore(e2e): remove pnpm install --- .github/workflows/e2e.yml | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 9fab716daa..9979c8f124 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -32,14 +32,8 @@ jobs: node-version: 20 cache: "pnpm" cache-dependency-path: pnpm-lock.yaml - - name: Install dependencies - run: pnpm install --filter e2e --frozen-lockfile - - name: Install Cypress binary - run: cd ./e2e && pnpm exec cypress install - name: Start DB and ZITADEL - run: | - cd ./e2e - ZITADEL_IMAGE=zitadel:local docker compose up --detach --wait + run: ZITADEL_IMAGE=zitadel:local docker compose --file e2e/docker-compose.yml up --detach --wait - name: Cypress run uses: cypress-io/github-action@v6 env: From 2504e304111192720f9425a615b5e367e2395428 Mon Sep 17 00:00:00 2001 From: Elio Bischof Date: Wed, 30 Jul 2025 19:14:13 +0200 Subject: [PATCH 13/16] file name --- .github/workflows/e2e.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 9979c8f124..adf668b3cd 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -33,7 +33,7 @@ jobs: cache: "pnpm" cache-dependency-path: pnpm-lock.yaml - name: Start DB and ZITADEL - run: ZITADEL_IMAGE=zitadel:local docker compose --file e2e/docker-compose.yml up --detach --wait + run: ZITADEL_IMAGE=zitadel:local docker compose --file e2e/docker-compose.yaml up --detach --wait - name: Cypress run uses: cypress-io/github-action@v6 env: From 3c30720002a47f8f4125d82c06edccb0fc3d1ec1 Mon Sep 17 00:00:00 2001 From: Elio Bischof Date: Wed, 30 Jul 2025 19:32:51 +0200 Subject: [PATCH 14/16] cypress install --- .github/workflows/e2e.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index adf668b3cd..18c8a34b63 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -34,6 +34,12 @@ jobs: cache-dependency-path: pnpm-lock.yaml - name: Start DB and ZITADEL run: ZITADEL_IMAGE=zitadel:local docker compose --file e2e/docker-compose.yaml up --detach --wait + - name: Cypress install + uses: cypress-io/github-action@v6 + with: + browser: ${{ matrix.browser }} + install: true + runTests: false - name: Cypress run uses: cypress-io/github-action@v6 env: @@ -45,7 +51,8 @@ jobs: working-directory: e2e browser: ${{ matrix.browser }} config-file: cypress.config.ts - install: ${{ matrix.browser == 'firefox' }} + install: false + runTests: true - uses: actions/upload-artifact@v4 if: always() with: From e6bb3246e83c038be2e868f83df6aa4c7ff4c6b4 Mon Sep 17 00:00:00 2001 From: Elio Bischof Date: Wed, 30 Jul 2025 22:31:58 +0200 Subject: [PATCH 15/16] no cache --- .github/workflows/e2e.yml | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 18c8a34b63..785f5f9e10 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -30,16 +30,14 @@ jobs: - uses: actions/setup-node@v4 with: node-version: 20 - cache: "pnpm" - cache-dependency-path: pnpm-lock.yaml + - name: Install dependencies + run: pnpm install + - name: Install Cypress binary + run: cd ./e2e && pnpm exec cypress install - name: Start DB and ZITADEL - run: ZITADEL_IMAGE=zitadel:local docker compose --file e2e/docker-compose.yaml up --detach --wait - - name: Cypress install - uses: cypress-io/github-action@v6 - with: - browser: ${{ matrix.browser }} - install: true - runTests: false + run: | + cd ./e2e + ZITADEL_IMAGE=zitadel:local docker compose up --detach --wait - name: Cypress run uses: cypress-io/github-action@v6 env: @@ -52,7 +50,6 @@ jobs: browser: ${{ matrix.browser }} config-file: cypress.config.ts install: false - runTests: true - uses: actions/upload-artifact@v4 if: always() with: From 51431529d11b07974daf3d1cbd3a2c76d4773436 Mon Sep 17 00:00:00 2001 From: Elio Bischof Date: Wed, 30 Jul 2025 22:42:19 +0200 Subject: [PATCH 16/16] update cypress --- .github/workflows/e2e.yml | 2 ++ e2e/package.json | 2 +- pnpm-lock.yaml | 48 ++++++++++++++++++++++++++++++--------- 3 files changed, 40 insertions(+), 12 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 785f5f9e10..b9c2159e1c 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -30,6 +30,8 @@ jobs: - uses: actions/setup-node@v4 with: node-version: 20 + cache: "pnpm" + cache-dependency-path: pnpm-lock.yaml - name: Install dependencies run: pnpm install - name: Install Cypress binary diff --git a/e2e/package.json b/e2e/package.json index b10f313c57..bf268a7de4 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -30,6 +30,6 @@ }, "devDependencies": { "@types/node": "^22.3.0", - "cypress": "^13.13.3" + "cypress": "^14.5.3" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 75cde1e27d..79a52ce530 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -576,8 +576,8 @@ importers: specifier: ^22.3.0 version: 22.16.5 cypress: - specifier: ^13.13.3 - version: 13.17.0 + specifier: ^14.5.3 + version: 14.5.3 packages/zitadel-client: dependencies: @@ -2154,6 +2154,10 @@ packages: resolution: {integrity: sha512-h0NFgh1mJmm1nr4jCwkGHwKneVYKghUyWe6TMNrk0B9zsjAJxpg8C4/+BAcmLgCPa1vj1V8rNUaILl+zYRUWBQ==} engines: {node: '>= 6'} + '@cypress/request@3.0.9': + resolution: {integrity: sha512-I3l7FdGRXluAS44/0NguwWlO83J18p0vlr2FYHrJkWdNYhgVoiYo61IXPqaOsL+vNxU1ZqMACzItGK3/KKDsdw==} + engines: {node: '>= 6'} + '@cypress/xvfb@1.2.4': resolution: {integrity: sha512-skbBzPggOVYCbnGgV+0dmBdW/s77ZkAOXIC1knS8NagwDjBrNC1LuXtQJeiN6l+m7lzmHtaoUw/ctJKdqkG57Q==} @@ -7033,16 +7037,16 @@ packages: cypress-wait-until@3.0.2: resolution: {integrity: sha512-iemies796dD5CgjG5kV0MnpEmKSH+s7O83ZoJLVzuVbZmm4lheMsZqAVT73hlMx4QlkwhxbyUzhOBUOZwoOe0w==} - cypress@13.17.0: - resolution: {integrity: sha512-5xWkaPurwkIljojFidhw8lFScyxhtiFHl/i/3zov+1Z5CmY4t9tjIdvSXfu82Y3w7wt0uR9KkucbhkVvJZLQSA==} - engines: {node: ^16.0.0 || ^18.0.0 || >=20.0.0} - hasBin: true - cypress@14.5.2: resolution: {integrity: sha512-O4E4CEBqDHLDrJD/dfStHPcM+8qFgVVZ89Li7xDU0yL/JxO/V0PEcfF2I8aGa7uA2MGNLkNUAnghPM83UcHOJw==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true + cypress@14.5.3: + resolution: {integrity: sha512-syLwKjDeMg77FRRx68bytLdlqHXDT4yBVh0/PPkcgesChYDjUZbwxLqMXuryYKzAyJsPsQHUDW1YU74/IYEUIA==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + cytoscape-cose-bilkent@4.1.0: resolution: {integrity: sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==} peerDependencies: @@ -16904,6 +16908,27 @@ snapshots: tunnel-agent: 0.6.0 uuid: 8.3.2 + '@cypress/request@3.0.9': + dependencies: + aws-sign2: 0.7.0 + aws4: 1.13.2 + caseless: 0.12.0 + combined-stream: 1.0.8 + extend: 3.0.2 + forever-agent: 0.6.1 + form-data: 4.0.4 + http-signature: 1.4.0 + is-typedarray: 1.0.0 + isstream: 0.1.2 + json-stringify-safe: 5.0.1 + mime-types: 2.1.35 + performance-now: 2.1.0 + qs: 6.14.0 + safe-buffer: 5.2.1 + tough-cookie: 5.1.2 + tunnel-agent: 0.6.0 + uuid: 8.3.2 + '@cypress/xvfb@1.2.4(supports-color@8.1.1)': dependencies: debug: 3.2.7(supports-color@8.1.1) @@ -23170,7 +23195,7 @@ snapshots: cypress-wait-until@3.0.2: {} - cypress@13.17.0: + cypress@14.5.2: dependencies: '@cypress/request': 3.0.8 '@cypress/xvfb': 1.2.4(supports-color@8.1.1) @@ -23185,7 +23210,7 @@ snapshots: check-more-types: 2.24.0 ci-info: 4.3.0 cli-cursor: 3.1.0 - cli-table3: 0.6.5 + cli-table3: 0.6.1 commander: 6.2.1 common-tags: 1.8.2 dayjs: 1.11.13 @@ -23198,6 +23223,7 @@ snapshots: figures: 3.2.0 fs-extra: 9.1.0 getos: 3.2.1 + hasha: 5.2.2 is-installed-globally: 0.4.0 lazy-ass: 1.6.0 listr2: 3.14.0(enquirer@2.4.1) @@ -23216,9 +23242,9 @@ snapshots: untildify: 4.0.0 yauzl: 2.10.0 - cypress@14.5.2: + cypress@14.5.3: dependencies: - '@cypress/request': 3.0.8 + '@cypress/request': 3.0.9 '@cypress/xvfb': 1.2.4(supports-color@8.1.1) '@types/sinonjs__fake-timers': 8.1.1 '@types/sizzle': 2.3.9