From 5fdad7b8f4218d925cf4d8c799c94d5697d216d5 Mon Sep 17 00:00:00 2001 From: Stefan Benz <46600784+stebenz@users.noreply.github.com> Date: Tue, 17 Sep 2024 10:27:48 +0200 Subject: [PATCH 01/19] feat: user v3 api update (#8582) # Which Problems Are Solved Users are not yet able to update their information an status in user API v3. # How the Problems Are Solved Add endpoints and functionality to update users and their status in user API v3. # Additional Changes Aggregate_type and event_types are updated with "userschema" to avoid conflicts with old events. # Additional Context closes #7898 --- .../integration_test/execution_target_test.go | 26 +- .../resources/object/v3alpha/converter.go | 15 + .../v3alpha/integration_test/server_test.go | 25 +- .../v3alpha/integration_test/user_test.go | 1344 ++++++++++- .../api/grpc/resources/user/v3alpha/user.go | 146 +- .../v3alpha/integration_test/query_test.go | 42 +- .../v3alpha/integration_test/server_test.go | 24 +- .../integration_test/userschema_test.go | 151 +- internal/command/user_schema.go | 2 +- internal/command/user_schema_model.go | 15 +- internal/command/user_v3.go | 264 ++- internal/command/user_v3_model.go | 66 +- internal/command/user_v3_test.go | 2029 ++++++++++++++++- internal/integration/client.go | 54 +- .../repository/user/schemauser/aggregate.go | 4 +- internal/repository/user/schemauser/email.go | 87 +- .../repository/user/schemauser/eventstore.go | 14 + internal/repository/user/schemauser/phone.go | 91 +- internal/repository/user/schemauser/user.go | 136 +- .../resources/user/v3alpha/user_service.proto | 142 +- 20 files changed, 4265 insertions(+), 412 deletions(-) diff --git a/internal/api/grpc/resources/action/v3alpha/integration_test/execution_target_test.go b/internal/api/grpc/resources/action/v3alpha/integration_test/execution_target_test.go index 2bdbdee066..169ee0e5d2 100644 --- a/internal/api/grpc/resources/action/v3alpha/integration_test/execution_target_test.go +++ b/internal/api/grpc/resources/action/v3alpha/integration_test/execution_target_test.go @@ -250,16 +250,26 @@ func TestServer_ExecutionTarget(t *testing.T) { require.NoError(t, err) defer close() } - - got, err := instance.Client.ActionV3Alpha.GetTarget(tt.ctx, tt.req) - if tt.wantErr { - require.Error(t, err) - return + retryDuration := 5 * time.Second + if ctxDeadline, ok := isolatedIAMOwnerCTX.Deadline(); ok { + retryDuration = time.Until(ctxDeadline) } - require.NoError(t, err) - integration.AssertResourceDetails(t, tt.want.GetTarget().GetDetails(), got.GetTarget().GetDetails()) - assert.Equal(t, tt.want.GetTarget().GetConfig(), got.GetTarget().GetConfig()) + require.EventuallyWithT(t, func(ttt *assert.CollectT) { + got, err := instance.Client.ActionV3Alpha.GetTarget(tt.ctx, tt.req) + if tt.wantErr { + assert.Error(ttt, err, "Error: "+err.Error()) + } else { + assert.NoError(ttt, err) + } + if err != nil { + return + } + + integration.AssertResourceDetails(t, tt.want.GetTarget().GetDetails(), got.GetTarget().GetDetails()) + assert.Equal(t, tt.want.GetTarget().GetConfig(), got.GetTarget().GetConfig()) + }, retryDuration, time.Millisecond*100, "timeout waiting for expected execution result") + if tt.clean != nil { tt.clean(tt.ctx) } diff --git a/internal/api/grpc/resources/object/v3alpha/converter.go b/internal/api/grpc/resources/object/v3alpha/converter.go index c2dc7bcc6d..869311b921 100644 --- a/internal/api/grpc/resources/object/v3alpha/converter.go +++ b/internal/api/grpc/resources/object/v3alpha/converter.go @@ -75,3 +75,18 @@ func SearchQueryPbToQuery(defaults systemdefaults.SystemDefaults, query *resourc } return offset, limit, asc, nil } + +func ResourceOwnerFromOrganization(organization *object.Organization) string { + if organization == nil { + return "" + } + + if organization.GetOrgId() != "" { + return organization.GetOrgId() + } + if organization.GetOrgDomain() != "" { + // TODO get org from domain + return "" + } + return "" +} diff --git a/internal/api/grpc/resources/user/v3alpha/integration_test/server_test.go b/internal/api/grpc/resources/user/v3alpha/integration_test/server_test.go index fa82005eb9..b2b11fd510 100644 --- a/internal/api/grpc/resources/user/v3alpha/integration_test/server_test.go +++ b/internal/api/grpc/resources/user/v3alpha/integration_test/server_test.go @@ -14,50 +14,41 @@ import ( "github.com/zitadel/zitadel/internal/integration" "github.com/zitadel/zitadel/pkg/grpc/feature/v2" - user "github.com/zitadel/zitadel/pkg/grpc/resources/user/v3alpha" ) var ( - IAMOwnerCTX, SystemCTX context.Context - UserCTX context.Context - Instance *integration.Instance - Client user.ZITADELUsersClient + CTX context.Context ) func TestMain(m *testing.M) { os.Exit(func() int { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) defer cancel() - - Instance = integration.NewInstance(ctx) - - IAMOwnerCTX = Instance.WithAuthorization(ctx, integration.UserTypeIAMOwner) - SystemCTX = integration.WithSystemAuthorization(ctx) - UserCTX = Instance.WithAuthorization(ctx, integration.UserTypeLogin) - Client = Instance.Client.UserV3Alpha + CTX = ctx return m.Run() }()) } -func ensureFeatureEnabled(t *testing.T, iamOwnerCTX context.Context) { - f, err := Instance.Client.FeatureV2.GetInstanceFeatures(iamOwnerCTX, &feature.GetInstanceFeaturesRequest{ +func ensureFeatureEnabled(t *testing.T, instance *integration.Instance) { + ctx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + f, err := instance.Client.FeatureV2.GetInstanceFeatures(ctx, &feature.GetInstanceFeaturesRequest{ Inheritance: true, }) require.NoError(t, err) if f.UserSchema.GetEnabled() { return } - _, err = Instance.Client.FeatureV2.SetInstanceFeatures(iamOwnerCTX, &feature.SetInstanceFeaturesRequest{ + _, err = instance.Client.FeatureV2.SetInstanceFeatures(ctx, &feature.SetInstanceFeaturesRequest{ UserSchema: gu.Ptr(true), }) require.NoError(t, err) retryDuration := time.Minute - if ctxDeadline, ok := iamOwnerCTX.Deadline(); ok { + if ctxDeadline, ok := ctx.Deadline(); ok { retryDuration = time.Until(ctxDeadline) } require.EventuallyWithT(t, func(ttt *assert.CollectT) { - f, err := Instance.Client.FeatureV2.GetInstanceFeatures(iamOwnerCTX, &feature.GetInstanceFeaturesRequest{ + f, err := instance.Client.FeatureV2.GetInstanceFeatures(ctx, &feature.GetInstanceFeaturesRequest{ Inheritance: true, }) require.NoError(ttt, err) diff --git a/internal/api/grpc/resources/user/v3alpha/integration_test/user_test.go b/internal/api/grpc/resources/user/v3alpha/integration_test/user_test.go index 7f15a2c562..c8db0f7f6a 100644 --- a/internal/api/grpc/resources/user/v3alpha/integration_test/user_test.go +++ b/internal/api/grpc/resources/user/v3alpha/integration_test/user_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/brianvoe/gofakeit/v6" + "github.com/muhlemmer/gu" "github.com/stretchr/testify/require" "github.com/zitadel/logging" "google.golang.org/protobuf/types/known/structpb" @@ -19,7 +20,11 @@ import ( ) func TestServer_CreateUser(t *testing.T) { - ensureFeatureEnabled(t, IAMOwnerCTX) + t.Parallel() + instance := integration.NewInstance(CTX) + ensureFeatureEnabled(t, instance) + isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + schema := []byte(`{ "$schema": "urn:zitadel:schema:v1", "type": "object", @@ -29,7 +34,7 @@ func TestServer_CreateUser(t *testing.T) { } } }`) - schemaResp := Instance.CreateUserSchema(IAMOwnerCTX, schema) + schemaResp := instance.CreateUserSchema(isolatedIAMOwnerCTX, schema) permissionSchema := []byte(`{ "$schema": "urn:zitadel:schema:v1", "type": "object", @@ -43,8 +48,8 @@ func TestServer_CreateUser(t *testing.T) { } } }`) - permissionSchemaResp := Instance.CreateUserSchema(IAMOwnerCTX, permissionSchema) - orgResp := Instance.CreateOrganization(IAMOwnerCTX, gofakeit.Name(), gofakeit.Email()) + permissionSchemaResp := instance.CreateUserSchema(isolatedIAMOwnerCTX, permissionSchema) + orgResp := instance.CreateOrganization(isolatedIAMOwnerCTX, gofakeit.Name(), gofakeit.Email()) type res struct { want *resource_object.Details @@ -60,7 +65,7 @@ func TestServer_CreateUser(t *testing.T) { }{ { name: "user create, no schemaID", - ctx: IAMOwnerCTX, + ctx: isolatedIAMOwnerCTX, req: &user.CreateUserRequest{ Organization: &object.Organization{ Property: &object.Organization_OrgId{ @@ -89,7 +94,7 @@ func TestServer_CreateUser(t *testing.T) { }, { name: "user create, no permission", - ctx: UserCTX, + ctx: instance.WithAuthorization(CTX, integration.UserTypeLogin), req: &user.CreateUserRequest{ Organization: &object.Organization{ Property: &object.Organization_OrgId{ @@ -105,7 +110,7 @@ func TestServer_CreateUser(t *testing.T) { }, { name: "user create, invalid schema permission, owner", - ctx: IAMOwnerCTX, + ctx: isolatedIAMOwnerCTX, req: &user.CreateUserRequest{ Organization: &object.Organization{ Property: &object.Organization_OrgId{ @@ -121,7 +126,7 @@ func TestServer_CreateUser(t *testing.T) { }, { name: "user create, no user data", - ctx: IAMOwnerCTX, + ctx: isolatedIAMOwnerCTX, req: &user.CreateUserRequest{ Organization: &object.Organization{ Property: &object.Organization_OrgId{ @@ -144,7 +149,7 @@ func TestServer_CreateUser(t *testing.T) { }, { name: "user create, ok", - ctx: IAMOwnerCTX, + ctx: isolatedIAMOwnerCTX, req: &user.CreateUserRequest{ Organization: &object.Organization{ Property: &object.Organization_OrgId{ @@ -165,9 +170,10 @@ func TestServer_CreateUser(t *testing.T) { }, }, }, - }, { + }, + { name: "user create, full contact, ok", - ctx: IAMOwnerCTX, + ctx: isolatedIAMOwnerCTX, req: &user.CreateUserRequest{ Organization: &object.Organization{ Property: &object.Organization_OrgId{ @@ -204,7 +210,423 @@ func TestServer_CreateUser(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := Instance.Client.UserV3Alpha.CreateUser(tt.ctx, tt.req) + got, err := instance.Client.UserV3Alpha.CreateUser(tt.ctx, tt.req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + integration.AssertResourceDetails(t, tt.res.want, got.Details) + if tt.res.returnCodeEmail { + require.NotNil(t, got.EmailCode) + } + if tt.res.returnCodePhone { + require.NotNil(t, got.PhoneCode) + } + }) + } +} + +func TestServer_PatchUser(t *testing.T) { + t.Parallel() + instance := integration.NewInstance(CTX) + ensureFeatureEnabled(t, instance) + isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + + schema := []byte(`{ + "$schema": "urn:zitadel:schema:v1", + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + }`) + schemaResp := instance.CreateUserSchema(isolatedIAMOwnerCTX, schema) + permissionSchema := []byte(`{ + "$schema": "urn:zitadel:schema:v1", + "type": "object", + "properties": { + "name": { + "urn:zitadel:schema:permission": { + "owner": "r", + "self": "r" + }, + "type": "string" + } + } + }`) + permissionSchemaResp := instance.CreateUserSchema(isolatedIAMOwnerCTX, permissionSchema) + orgResp := instance.CreateOrganization(isolatedIAMOwnerCTX, gofakeit.Name(), gofakeit.Email()) + + type res struct { + want *resource_object.Details + returnCodeEmail bool + returnCodePhone bool + } + tests := []struct { + name string + ctx context.Context + dep func(req *user.PatchUserRequest) error + req *user.PatchUserRequest + res res + wantErr bool + }{ + { + name: "user patch, no context", + ctx: context.Background(), + dep: func(req *user.PatchUserRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + return nil + }, + req: &user.PatchUserRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + User: &user.PatchUser{ + Data: unmarshalJSON("{\"name\": \"user\"}"), + }, + }, + wantErr: true, + }, + { + name: "user patch, no permission", + ctx: instance.WithAuthorization(CTX, integration.UserTypeLogin), + dep: func(req *user.PatchUserRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + return nil + }, + req: &user.PatchUserRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + User: &user.PatchUser{ + Data: unmarshalJSON("{\"name\": \"user\"}"), + }, + }, + wantErr: true, + }, + { + name: "user patch, invalid schema permission, owner", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.PatchUserRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + return nil + }, + req: &user.PatchUserRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + User: &user.PatchUser{ + SchemaId: gu.Ptr(permissionSchemaResp.GetDetails().GetId()), + Data: unmarshalJSON("{\"name\": \"user\"}"), + }, + }, + wantErr: true, + }, + { + name: "user patch, not found", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.PatchUserRequest) error { + return nil + }, + req: &user.PatchUserRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + Id: "notexisting", + User: &user.PatchUser{ + Data: unmarshalJSON("{\"name\": \"user\"}"), + }, + }, + wantErr: true, + }, + { + name: "user patch, not found, org", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.PatchUserRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + return nil + }, + req: &user.PatchUserRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: "not existing", + }, + }, + User: &user.PatchUser{ + Data: unmarshalJSON("{\"name\": \"user\"}"), + }, + }, + wantErr: true, + }, + { + name: "user patch, no change", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.PatchUserRequest) error { + data := "{\"name\": \"user\"}" + schemaID := schemaResp.GetDetails().GetId() + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaID, []byte(data)) + req.Id = userResp.GetDetails().GetId() + req.User.Data = unmarshalJSON(data) + req.User.SchemaId = gu.Ptr(schemaID) + return nil + }, + req: &user.PatchUserRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + User: &user.PatchUser{}, + }, + res: res{ + want: &resource_object.Details{ + Changed: timestamppb.Now(), + Owner: &object.Owner{ + Type: object.OwnerType_OWNER_TYPE_ORG, + Id: orgResp.GetOrganizationId(), + }, + }, + }, + }, + { + name: "user patch, schema, ok", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.PatchUserRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + changedSchemaResp := instance.CreateUserSchema(isolatedIAMOwnerCTX, schema) + req.User.SchemaId = gu.Ptr(changedSchemaResp.Details.Id) + return nil + }, + req: &user.PatchUserRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + User: &user.PatchUser{}, + }, + res: res{ + want: &resource_object.Details{ + Changed: timestamppb.Now(), + Owner: &object.Owner{ + Type: object.OwnerType_OWNER_TYPE_ORG, + Id: orgResp.GetOrganizationId(), + }, + }, + }, + }, + { + name: "user patch, schema and data, ok", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.PatchUserRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + changedSchemaResp := instance.CreateUserSchema(isolatedIAMOwnerCTX, schema) + req.User.SchemaId = gu.Ptr(changedSchemaResp.Details.Id) + return nil + }, + req: &user.PatchUserRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + User: &user.PatchUser{ + Data: unmarshalJSON("{\"name\": \"changed\"}"), + }, + }, + res: res{ + want: &resource_object.Details{ + Changed: timestamppb.Now(), + Owner: &object.Owner{ + Type: object.OwnerType_OWNER_TYPE_ORG, + Id: orgResp.GetOrganizationId(), + }, + }, + }, + }, + { + name: "user patch, ok", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.PatchUserRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + return nil + }, + req: &user.PatchUserRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + User: &user.PatchUser{ + Data: unmarshalJSON("{\"name\": \"changed\"}"), + }, + }, + res: res{ + want: &resource_object.Details{ + Changed: timestamppb.Now(), + Owner: &object.Owner{ + Type: object.OwnerType_OWNER_TYPE_ORG, + Id: orgResp.GetOrganizationId(), + }, + }, + }, + }, + { + name: "user patch, no org, ok", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.PatchUserRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + return nil + }, + req: &user.PatchUserRequest{ + User: &user.PatchUser{ + Data: unmarshalJSON("{\"name\": \"changed\"}"), + }, + }, + res: res{ + want: &resource_object.Details{ + Changed: timestamppb.Now(), + Owner: &object.Owner{ + Type: object.OwnerType_OWNER_TYPE_ORG, + Id: orgResp.GetOrganizationId(), + }, + }, + }, + }, + { + name: "user patch, contact email, ok", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.PatchUserRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + return nil + }, + req: &user.PatchUserRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + User: &user.PatchUser{ + Contact: &user.SetContact{ + Email: &user.SetEmail{ + Address: gofakeit.Email(), + Verification: &user.SetEmail_ReturnCode{ReturnCode: &user.ReturnEmailVerificationCode{}}, + }, + }, + }, + }, + res: res{ + want: &resource_object.Details{ + Changed: timestamppb.Now(), + Owner: &object.Owner{ + Type: object.OwnerType_OWNER_TYPE_ORG, + Id: orgResp.GetOrganizationId(), + }, + }, + returnCodeEmail: true, + }, + }, + { + name: "user patch, contact phone, ok", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.PatchUserRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + return nil + }, + req: &user.PatchUserRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + User: &user.PatchUser{ + Contact: &user.SetContact{ + Phone: &user.SetPhone{ + Number: gofakeit.Phone(), + Verification: &user.SetPhone_ReturnCode{ReturnCode: &user.ReturnPhoneVerificationCode{}}, + }, + }, + }, + }, + res: res{ + want: &resource_object.Details{ + Changed: timestamppb.Now(), + Owner: &object.Owner{ + Type: object.OwnerType_OWNER_TYPE_ORG, + Id: orgResp.GetOrganizationId(), + }, + }, + returnCodePhone: true, + }, + }, + { + name: "user patch, full contact, ok", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.PatchUserRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + return nil + }, + req: &user.PatchUserRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + User: &user.PatchUser{ + Data: unmarshalJSON("{\"name\": \"changed\"}"), + Contact: &user.SetContact{ + Email: &user.SetEmail{ + Address: gofakeit.Email(), + Verification: &user.SetEmail_ReturnCode{ReturnCode: &user.ReturnEmailVerificationCode{}}, + }, + Phone: &user.SetPhone{ + Number: gofakeit.Phone(), + Verification: &user.SetPhone_ReturnCode{ReturnCode: &user.ReturnPhoneVerificationCode{}}, + }, + }, + }, + }, + res: res{ + want: &resource_object.Details{ + Changed: timestamppb.Now(), + Owner: &object.Owner{ + Type: object.OwnerType_OWNER_TYPE_ORG, + Id: orgResp.GetOrganizationId(), + }, + }, + returnCodePhone: true, + returnCodeEmail: true, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.dep != nil { + err := tt.dep(tt.req) + require.NoError(t, err) + } + got, err := instance.Client.UserV3Alpha.PatchUser(tt.ctx, tt.req) if tt.wantErr { require.Error(t, err) return @@ -222,7 +644,11 @@ func TestServer_CreateUser(t *testing.T) { } func TestServer_DeleteUser(t *testing.T) { - ensureFeatureEnabled(t, IAMOwnerCTX) + t.Parallel() + instance := integration.NewInstance(CTX) + ensureFeatureEnabled(t, instance) + isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + schema := []byte(`{ "$schema": "urn:zitadel:schema:v1", "type": "object", @@ -232,49 +658,66 @@ func TestServer_DeleteUser(t *testing.T) { } } }`) - schemaResp := Instance.CreateUserSchema(IAMOwnerCTX, schema) - orgResp := Instance.CreateOrganization(IAMOwnerCTX, gofakeit.Name(), gofakeit.Email()) + schemaResp := instance.CreateUserSchema(isolatedIAMOwnerCTX, schema) + orgResp := instance.CreateOrganization(isolatedIAMOwnerCTX, gofakeit.Name(), gofakeit.Email()) tests := []struct { name string ctx context.Context - dep func(ctx context.Context, req *user.DeleteUserRequest) error + dep func(req *user.DeleteUserRequest) error req *user.DeleteUserRequest want *resource_object.Details wantErr bool }{ { name: "user delete, no userID", - ctx: IAMOwnerCTX, + ctx: isolatedIAMOwnerCTX, req: &user.DeleteUserRequest{ Organization: &object.Organization{ Property: &object.Organization_OrgId{ OrgId: orgResp.GetOrganizationId(), }, }, - UserId: "", + Id: "", }, wantErr: true, }, { name: "user delete, not existing", - ctx: IAMOwnerCTX, + ctx: isolatedIAMOwnerCTX, req: &user.DeleteUserRequest{ Organization: &object.Organization{ Property: &object.Organization_OrgId{ OrgId: orgResp.GetOrganizationId(), }, }, - UserId: "notexisting", + Id: "notexisting", + }, + wantErr: true, + }, + { + name: "user delete, not existing, org", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.DeleteUserRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + return nil + }, + req: &user.DeleteUserRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: "notexisting", + }, + }, }, wantErr: true, }, { name: "user delete, no context", ctx: context.Background(), - dep: func(ctx context.Context, req *user.DeleteUserRequest) error { - userResp := Instance.CreateSchemaUser(IAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) - req.UserId = userResp.GetDetails().GetId() + dep: func(req *user.DeleteUserRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() return nil }, req: &user.DeleteUserRequest{ @@ -288,10 +731,10 @@ func TestServer_DeleteUser(t *testing.T) { }, { name: "user delete, no permission", - ctx: UserCTX, - dep: func(ctx context.Context, req *user.DeleteUserRequest) error { - userResp := Instance.CreateSchemaUser(IAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) - req.UserId = userResp.GetDetails().GetId() + ctx: instance.WithAuthorization(CTX, integration.UserTypeLogin), + dep: func(req *user.DeleteUserRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() return nil }, req: &user.DeleteUserRequest{ @@ -305,10 +748,75 @@ func TestServer_DeleteUser(t *testing.T) { }, { name: "user delete, ok", - ctx: IAMOwnerCTX, - dep: func(ctx context.Context, req *user.DeleteUserRequest) error { - userResp := Instance.CreateSchemaUser(ctx, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) - req.UserId = userResp.GetDetails().GetId() + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.DeleteUserRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + return nil + }, + req: &user.DeleteUserRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + }, + want: &resource_object.Details{ + Changed: timestamppb.Now(), + Owner: &object.Owner{ + Type: object.OwnerType_OWNER_TYPE_ORG, + Id: orgResp.GetOrganizationId(), + }, + }, + }, + { + name: "user delete, no org, ok", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.DeleteUserRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + return nil + }, + req: &user.DeleteUserRequest{}, + want: &resource_object.Details{ + Changed: timestamppb.Now(), + Owner: &object.Owner{ + Type: object.OwnerType_OWNER_TYPE_ORG, + Id: orgResp.GetOrganizationId(), + }, + }, + }, + { + name: "user delete, locked, ok", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.DeleteUserRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + instance.LockSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), req.Id) + return nil + }, + req: &user.DeleteUserRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + }, + want: &resource_object.Details{ + Changed: timestamppb.Now(), + Owner: &object.Owner{ + Type: object.OwnerType_OWNER_TYPE_ORG, + Id: orgResp.GetOrganizationId(), + }, + }, + }, + { + name: "user delete, deactivated, ok", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.DeleteUserRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + instance.DeactivateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), req.Id) return nil }, req: &user.DeleteUserRequest{ @@ -330,10 +838,10 @@ func TestServer_DeleteUser(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if tt.dep != nil { - err := tt.dep(tt.ctx, tt.req) + err := tt.dep(tt.req) require.NoError(t, err) } - got, err := Instance.Client.UserV3Alpha.DeleteUser(tt.ctx, tt.req) + got, err := instance.Client.UserV3Alpha.DeleteUser(tt.ctx, tt.req) if tt.wantErr { require.Error(t, err) return @@ -352,3 +860,773 @@ func unmarshalJSON(data string) *structpb.Struct { } return user } + +func TestServer_LockUser(t *testing.T) { + t.Parallel() + instance := integration.NewInstance(CTX) + ensureFeatureEnabled(t, instance) + isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + schema := []byte(`{ + "$schema": "urn:zitadel:schema:v1", + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + }`) + schemaResp := instance.CreateUserSchema(isolatedIAMOwnerCTX, schema) + orgResp := instance.CreateOrganization(isolatedIAMOwnerCTX, gofakeit.Name(), gofakeit.Email()) + + tests := []struct { + name string + ctx context.Context + dep func(req *user.LockUserRequest) error + req *user.LockUserRequest + want *resource_object.Details + wantErr bool + }{ + { + name: "user lock, no userID", + ctx: isolatedIAMOwnerCTX, + req: &user.LockUserRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + Id: "", + }, + wantErr: true, + }, + { + name: "user lock, not existing", + ctx: isolatedIAMOwnerCTX, + req: &user.LockUserRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + Id: "notexisting", + }, + wantErr: true, + }, + { + name: "user lock, not existing in org", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.LockUserRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + return nil + }, + req: &user.LockUserRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: "notexisting", + }, + }, + }, + wantErr: true, + }, + { + name: "user lock, no context", + ctx: context.Background(), + dep: func(req *user.LockUserRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + return nil + }, + req: &user.LockUserRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + }, + wantErr: true, + }, + { + name: "user lock, no permission", + ctx: instance.WithAuthorization(CTX, integration.UserTypeLogin), + dep: func(req *user.LockUserRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + return nil + }, + req: &user.LockUserRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + }, + wantErr: true, + }, + { + name: "user lock, ok", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.LockUserRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + return nil + }, + req: &user.LockUserRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + }, + want: &resource_object.Details{ + Changed: timestamppb.Now(), + Owner: &object.Owner{ + Type: object.OwnerType_OWNER_TYPE_ORG, + Id: orgResp.GetOrganizationId(), + }, + }, + }, + { + name: "user lock, no org, ok", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.LockUserRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + return nil + }, + req: &user.LockUserRequest{}, + want: &resource_object.Details{ + Changed: timestamppb.Now(), + Owner: &object.Owner{ + Type: object.OwnerType_OWNER_TYPE_ORG, + Id: orgResp.GetOrganizationId(), + }, + }, + }, + { + name: "user lock, already locked", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.LockUserRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + instance.LockSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), req.Id) + return nil + }, + req: &user.LockUserRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + }, + wantErr: true, + }, + { + name: "user lock, deactivated", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.LockUserRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + instance.DeactivateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), req.Id) + return nil + }, + req: &user.LockUserRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + }, + want: &resource_object.Details{ + Changed: timestamppb.Now(), + Owner: &object.Owner{ + Type: object.OwnerType_OWNER_TYPE_ORG, + Id: orgResp.GetOrganizationId(), + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.dep != nil { + err := tt.dep(tt.req) + require.NoError(t, err) + } + got, err := instance.Client.UserV3Alpha.LockUser(tt.ctx, tt.req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + integration.AssertResourceDetails(t, tt.want, got.Details) + }) + } +} + +func TestServer_UnlockUser(t *testing.T) { + t.Parallel() + instance := integration.NewInstance(CTX) + ensureFeatureEnabled(t, instance) + isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + schema := []byte(`{ + "$schema": "urn:zitadel:schema:v1", + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + }`) + schemaResp := instance.CreateUserSchema(isolatedIAMOwnerCTX, schema) + orgResp := instance.CreateOrganization(isolatedIAMOwnerCTX, gofakeit.Name(), gofakeit.Email()) + + tests := []struct { + name string + ctx context.Context + dep func(req *user.UnlockUserRequest) error + req *user.UnlockUserRequest + want *resource_object.Details + wantErr bool + }{ + { + name: "user unlock, no userID", + ctx: isolatedIAMOwnerCTX, + req: &user.UnlockUserRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + Id: "", + }, + wantErr: true, + }, + { + name: "user unlock, not existing", + ctx: isolatedIAMOwnerCTX, + req: &user.UnlockUserRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + Id: "notexisting", + }, + wantErr: true, + }, + { + name: "user unlock, not existing in org", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.UnlockUserRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + return nil + }, + req: &user.UnlockUserRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: "notexisting", + }, + }, + }, + wantErr: true, + }, + { + name: "user unlock, no context", + ctx: context.Background(), + dep: func(req *user.UnlockUserRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + instance.LockSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), req.Id) + return nil + }, + req: &user.UnlockUserRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + }, + wantErr: true, + }, + { + name: "user unlock, no permission", + ctx: instance.WithAuthorization(CTX, integration.UserTypeLogin), + dep: func(req *user.UnlockUserRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + instance.LockSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), req.Id) + return nil + }, + req: &user.UnlockUserRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + }, + wantErr: true, + }, + { + name: "user unlock, ok", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.UnlockUserRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + instance.LockSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), req.Id) + return nil + }, + req: &user.UnlockUserRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + }, + want: &resource_object.Details{ + Changed: timestamppb.Now(), + Owner: &object.Owner{ + Type: object.OwnerType_OWNER_TYPE_ORG, + Id: orgResp.GetOrganizationId(), + }, + }, + }, + { + name: "user unlock,no org, ok", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.UnlockUserRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + instance.LockSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), req.Id) + return nil + }, + req: &user.UnlockUserRequest{}, + want: &resource_object.Details{ + Changed: timestamppb.Now(), + Owner: &object.Owner{ + Type: object.OwnerType_OWNER_TYPE_ORG, + Id: orgResp.GetOrganizationId(), + }, + }, + }, + { + name: "user unlock, already unlocked", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.UnlockUserRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + instance.LockSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), req.Id) + instance.UnlockSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), req.Id) + return nil + }, + req: &user.UnlockUserRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.dep != nil { + err := tt.dep(tt.req) + require.NoError(t, err) + } + got, err := instance.Client.UserV3Alpha.UnlockUser(tt.ctx, tt.req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + integration.AssertResourceDetails(t, tt.want, got.Details) + }) + } +} + +func TestServer_DeactivateUser(t *testing.T) { + t.Parallel() + instance := integration.NewInstance(CTX) + ensureFeatureEnabled(t, instance) + isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + schema := []byte(`{ + "$schema": "urn:zitadel:schema:v1", + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + }`) + schemaResp := instance.CreateUserSchema(isolatedIAMOwnerCTX, schema) + orgResp := instance.CreateOrganization(isolatedIAMOwnerCTX, gofakeit.Name(), gofakeit.Email()) + + tests := []struct { + name string + ctx context.Context + dep func(req *user.DeactivateUserRequest) error + req *user.DeactivateUserRequest + want *resource_object.Details + wantErr bool + }{ + { + name: "user deactivate, no userID", + ctx: isolatedIAMOwnerCTX, + req: &user.DeactivateUserRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + Id: "", + }, + wantErr: true, + }, + { + name: "user deactivate, not existing", + ctx: isolatedIAMOwnerCTX, + req: &user.DeactivateUserRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + Id: "notexisting", + }, + wantErr: true, + }, + { + name: "user deactivate, not existing in org", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.DeactivateUserRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + return nil + }, + req: &user.DeactivateUserRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: "notexisting", + }, + }, + }, + wantErr: true, + }, + { + name: "user deactivate, no context", + ctx: context.Background(), + dep: func(req *user.DeactivateUserRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + return nil + }, + req: &user.DeactivateUserRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + }, + wantErr: true, + }, + { + name: "user deactivate, no permission", + ctx: instance.WithAuthorization(CTX, integration.UserTypeLogin), + dep: func(req *user.DeactivateUserRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + return nil + }, + req: &user.DeactivateUserRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + }, + wantErr: true, + }, + { + name: "user deactivate, ok", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.DeactivateUserRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + return nil + }, + req: &user.DeactivateUserRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + }, + want: &resource_object.Details{ + Changed: timestamppb.Now(), + Owner: &object.Owner{ + Type: object.OwnerType_OWNER_TYPE_ORG, + Id: orgResp.GetOrganizationId(), + }, + }, + }, + { + name: "user deactivate, no org, ok", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.DeactivateUserRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + return nil + }, + req: &user.DeactivateUserRequest{}, + want: &resource_object.Details{ + Changed: timestamppb.Now(), + Owner: &object.Owner{ + Type: object.OwnerType_OWNER_TYPE_ORG, + Id: orgResp.GetOrganizationId(), + }, + }, + }, + { + name: "user deactivate, already deactivated", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.DeactivateUserRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + instance.DeactivateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), req.Id) + return nil + }, + req: &user.DeactivateUserRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + }, + wantErr: true, + }, + { + name: "user deactivate, locked", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.DeactivateUserRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + instance.LockSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), req.Id) + return nil + }, + req: &user.DeactivateUserRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + }, + want: &resource_object.Details{ + Changed: timestamppb.Now(), + Owner: &object.Owner{ + Type: object.OwnerType_OWNER_TYPE_ORG, + Id: orgResp.GetOrganizationId(), + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.dep != nil { + err := tt.dep(tt.req) + require.NoError(t, err) + } + got, err := instance.Client.UserV3Alpha.DeactivateUser(tt.ctx, tt.req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + integration.AssertResourceDetails(t, tt.want, got.Details) + }) + } +} + +func TestServer_ActivateUser(t *testing.T) { + t.Parallel() + instance := integration.NewInstance(CTX) + ensureFeatureEnabled(t, instance) + isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + schema := []byte(`{ + "$schema": "urn:zitadel:schema:v1", + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + }`) + schemaResp := instance.CreateUserSchema(isolatedIAMOwnerCTX, schema) + orgResp := instance.CreateOrganization(isolatedIAMOwnerCTX, gofakeit.Name(), gofakeit.Email()) + + tests := []struct { + name string + ctx context.Context + dep func(req *user.ActivateUserRequest) error + req *user.ActivateUserRequest + want *resource_object.Details + wantErr bool + }{ + { + name: "user activate, no userID", + ctx: isolatedIAMOwnerCTX, + req: &user.ActivateUserRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + Id: "", + }, + wantErr: true, + }, + { + name: "user activate, not existing", + ctx: isolatedIAMOwnerCTX, + req: &user.ActivateUserRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + Id: "notexisting", + }, + wantErr: true, + }, + { + name: "user activate, not existing in org", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.ActivateUserRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + return nil + }, + req: &user.ActivateUserRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: "notexisting", + }, + }, + }, + wantErr: true, + }, + { + name: "user activate, no context", + ctx: context.Background(), + dep: func(req *user.ActivateUserRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + instance.DeactivateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), req.Id) + return nil + }, + req: &user.ActivateUserRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + }, + wantErr: true, + }, + { + name: "user activate, no permission", + ctx: instance.WithAuthorization(CTX, integration.UserTypeLogin), + dep: func(req *user.ActivateUserRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + instance.DeactivateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), req.Id) + return nil + }, + req: &user.ActivateUserRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + }, + wantErr: true, + }, + { + name: "user activate, ok", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.ActivateUserRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + instance.DeactivateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), req.Id) + return nil + }, + req: &user.ActivateUserRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + }, + want: &resource_object.Details{ + Changed: timestamppb.Now(), + Owner: &object.Owner{ + Type: object.OwnerType_OWNER_TYPE_ORG, + Id: orgResp.GetOrganizationId(), + }, + }, + }, + { + name: "user activate, no org, ok", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.ActivateUserRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + instance.DeactivateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), req.Id) + return nil + }, + req: &user.ActivateUserRequest{}, + want: &resource_object.Details{ + Changed: timestamppb.Now(), + Owner: &object.Owner{ + Type: object.OwnerType_OWNER_TYPE_ORG, + Id: orgResp.GetOrganizationId(), + }, + }, + }, + { + name: "user activate, already activated", + ctx: isolatedIAMOwnerCTX, + dep: func(req *user.ActivateUserRequest) error { + userResp := instance.CreateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), schemaResp.GetDetails().GetId(), []byte("{\"name\": \"user\"}")) + req.Id = userResp.GetDetails().GetId() + instance.DeactivateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), req.Id) + instance.ActivateSchemaUser(isolatedIAMOwnerCTX, orgResp.GetOrganizationId(), req.Id) + return nil + }, + req: &user.ActivateUserRequest{ + Organization: &object.Organization{ + Property: &object.Organization_OrgId{ + OrgId: orgResp.GetOrganizationId(), + }, + }, + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.dep != nil { + err := tt.dep(tt.req) + require.NoError(t, err) + } + got, err := instance.Client.UserV3Alpha.ActivateUser(tt.ctx, tt.req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + integration.AssertResourceDetails(t, tt.want, got.Details) + }) + } +} diff --git a/internal/api/grpc/resources/user/v3alpha/user.go b/internal/api/grpc/resources/user/v3alpha/user.go index ede2f122aa..7c3f2a750e 100644 --- a/internal/api/grpc/resources/user/v3alpha/user.go +++ b/internal/api/grpc/resources/user/v3alpha/user.go @@ -8,6 +8,7 @@ import ( "github.com/zitadel/zitadel/internal/api/authz" resource_object "github.com/zitadel/zitadel/internal/api/grpc/resources/object/v3alpha" "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/zerrors" object "github.com/zitadel/zitadel/pkg/grpc/object/v3alpha" "github.com/zitadel/zitadel/pkg/grpc/resources/user/v3alpha" @@ -26,7 +27,7 @@ func (s *Server) CreateUser(ctx context.Context, req *user.CreateUserRequest) (_ return nil, err } return &user.CreateUserResponse{ - Details: resource_object.DomainToDetailsPb(schemauser.Details, object.OwnerType_OWNER_TYPE_ORG, schemauser.ResourceOwner), + Details: resource_object.DomainToDetailsPb(schemauser.Details, object.OwnerType_OWNER_TYPE_ORG, schemauser.Details.ResourceOwner), EmailCode: gu.Ptr(schemauser.ReturnCodeEmail), PhoneCode: gu.Ptr(schemauser.ReturnCodePhone), }, nil @@ -37,19 +38,35 @@ func createUserRequestToCreateSchemaUser(ctx context.Context, req *user.CreateUs if err != nil { return nil, err } + return &command.CreateSchemaUser{ - ResourceOwner: authz.GetCtxData(ctx).OrgID, + ResourceOwner: organizationToCreateResourceOwner(ctx, req.Organization), SchemaID: req.GetUser().GetSchemaId(), ID: req.GetUser().GetUserId(), Data: data, }, nil } +func organizationToCreateResourceOwner(ctx context.Context, org *object.Organization) string { + resourceOwner := authz.GetCtxData(ctx).OrgID + if resourceOwnerReq := resource_object.ResourceOwnerFromOrganization(org); resourceOwnerReq != "" { + return resourceOwnerReq + } + return resourceOwner +} + +func organizationToUpdateResourceOwner(org *object.Organization) string { + if resourceOwnerReq := resource_object.ResourceOwnerFromOrganization(org); resourceOwnerReq != "" { + return resourceOwnerReq + } + return "" +} + func (s *Server) DeleteUser(ctx context.Context, req *user.DeleteUserRequest) (_ *user.DeleteUserResponse, err error) { if err := checkUserSchemaEnabled(ctx); err != nil { return nil, err } - details, err := s.command.DeleteSchemaUser(ctx, req.GetUserId()) + details, err := s.command.DeleteSchemaUser(ctx, organizationToUpdateResourceOwner(req.Organization), req.GetId()) if err != nil { return nil, err } @@ -64,3 +81,126 @@ func checkUserSchemaEnabled(ctx context.Context) error { } return zerrors.ThrowPreconditionFailed(nil, "TODO", "Errors.UserSchema.NotEnabled") } + +func (s *Server) PatchUser(ctx context.Context, req *user.PatchUserRequest) (_ *user.PatchUserResponse, err error) { + if err := checkUserSchemaEnabled(ctx); err != nil { + return nil, err + } + schemauser, err := patchUserRequestToChangeSchemaUser(req) + if err != nil { + return nil, err + } + + if err := s.command.ChangeSchemaUser(ctx, schemauser, s.userCodeAlg); err != nil { + return nil, err + } + return &user.PatchUserResponse{ + Details: resource_object.DomainToDetailsPb(schemauser.Details, object.OwnerType_OWNER_TYPE_ORG, schemauser.Details.ResourceOwner), + EmailCode: gu.Ptr(schemauser.ReturnCodeEmail), + PhoneCode: gu.Ptr(schemauser.ReturnCodePhone), + }, nil +} + +func patchUserRequestToChangeSchemaUser(req *user.PatchUserRequest) (_ *command.ChangeSchemaUser, err error) { + var data []byte + if req.GetUser().Data != nil { + data, err = req.GetUser().GetData().MarshalJSON() + if err != nil { + return nil, err + } + } + + var email *command.Email + var phone *command.Phone + if req.GetUser().GetContact() != nil { + if req.GetUser().GetContact().GetEmail() != nil { + email = &command.Email{ + Address: domain.EmailAddress(req.GetUser().GetContact().Email.Address), + } + if req.GetUser().GetContact().Email.GetIsVerified() { + email.Verified = true + } + if req.GetUser().GetContact().Email.GetReturnCode() != nil { + email.ReturnCode = true + } + if req.GetUser().GetContact().Email.GetSendCode() != nil { + email.URLTemplate = req.GetUser().GetContact().Email.GetSendCode().GetUrlTemplate() + } + } + if req.GetUser().GetContact().Phone != nil { + phone = &command.Phone{ + Number: domain.PhoneNumber(req.GetUser().GetContact().Phone.Number), + } + if req.GetUser().GetContact().Phone.GetIsVerified() { + phone.Verified = true + } + if req.GetUser().GetContact().Phone.GetReturnCode() != nil { + phone.ReturnCode = true + } + } + } + return &command.ChangeSchemaUser{ + ResourceOwner: organizationToUpdateResourceOwner(req.Organization), + ID: req.GetId(), + SchemaID: req.GetUser().SchemaId, + Data: data, + Email: email, + Phone: phone, + }, nil +} + +func (s *Server) DeactivateUser(ctx context.Context, req *user.DeactivateUserRequest) (_ *user.DeactivateUserResponse, err error) { + if err := checkUserSchemaEnabled(ctx); err != nil { + return nil, err + } + + details, err := s.command.DeactivateSchemaUser(ctx, organizationToUpdateResourceOwner(req.Organization), req.GetId()) + if err != nil { + return nil, err + } + return &user.DeactivateUserResponse{ + Details: resource_object.DomainToDetailsPb(details, object.OwnerType_OWNER_TYPE_ORG, details.ResourceOwner), + }, nil +} + +func (s *Server) ActivateUser(ctx context.Context, req *user.ActivateUserRequest) (_ *user.ActivateUserResponse, err error) { + if err := checkUserSchemaEnabled(ctx); err != nil { + return nil, err + } + + details, err := s.command.ActivateSchemaUser(ctx, organizationToUpdateResourceOwner(req.Organization), req.GetId()) + if err != nil { + return nil, err + } + return &user.ActivateUserResponse{ + Details: resource_object.DomainToDetailsPb(details, object.OwnerType_OWNER_TYPE_ORG, details.ResourceOwner), + }, nil +} + +func (s *Server) LockUser(ctx context.Context, req *user.LockUserRequest) (_ *user.LockUserResponse, err error) { + if err := checkUserSchemaEnabled(ctx); err != nil { + return nil, err + } + + details, err := s.command.LockSchemaUser(ctx, organizationToUpdateResourceOwner(req.Organization), req.GetId()) + if err != nil { + return nil, err + } + return &user.LockUserResponse{ + Details: resource_object.DomainToDetailsPb(details, object.OwnerType_OWNER_TYPE_ORG, details.ResourceOwner), + }, nil +} + +func (s *Server) UnlockUser(ctx context.Context, req *user.UnlockUserRequest) (_ *user.UnlockUserResponse, err error) { + if err := checkUserSchemaEnabled(ctx); err != nil { + return nil, err + } + + details, err := s.command.UnlockSchemaUser(ctx, organizationToUpdateResourceOwner(req.Organization), req.GetId()) + if err != nil { + return nil, err + } + return &user.UnlockUserResponse{ + Details: resource_object.DomainToDetailsPb(details, object.OwnerType_OWNER_TYPE_ORG, details.ResourceOwner), + }, nil +} diff --git a/internal/api/grpc/resources/userschema/v3alpha/integration_test/query_test.go b/internal/api/grpc/resources/userschema/v3alpha/integration_test/query_test.go index e7aaf081ed..36cf032660 100644 --- a/internal/api/grpc/resources/userschema/v3alpha/integration_test/query_test.go +++ b/internal/api/grpc/resources/userschema/v3alpha/integration_test/query_test.go @@ -20,7 +20,10 @@ import ( ) func TestServer_ListUserSchemas(t *testing.T) { - ensureFeatureEnabled(t, IAMOwnerCTX) + t.Parallel() + instance := integration.NewInstance(CTX) + ensureFeatureEnabled(t, instance) + isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) userSchema := new(structpb.Struct) err := userSchema.UnmarshalJSON([]byte(`{ @@ -43,7 +46,7 @@ func TestServer_ListUserSchemas(t *testing.T) { { name: "missing permission", args: args{ - ctx: Instance.WithAuthorization(context.Background(), integration.UserTypeOrgOwner), + ctx: instance.WithAuthorization(context.Background(), integration.UserTypeOrgOwner), req: &schema.SearchUserSchemasRequest{}, }, wantErr: true, @@ -51,7 +54,7 @@ func TestServer_ListUserSchemas(t *testing.T) { { name: "not found, error", args: args{ - ctx: IAMOwnerCTX, + ctx: isolatedIAMOwnerCTX, req: &schema.SearchUserSchemasRequest{ Filters: []*schema.SearchFilter{ { @@ -75,11 +78,11 @@ func TestServer_ListUserSchemas(t *testing.T) { { name: "single (id), ok", args: args{ - ctx: IAMOwnerCTX, + ctx: isolatedIAMOwnerCTX, req: &schema.SearchUserSchemasRequest{}, prepare: func(request *schema.SearchUserSchemasRequest, resp *schema.SearchUserSchemasResponse) error { schemaType := gofakeit.Name() - createResp := Instance.CreateUserSchemaEmptyWithType(IAMOwnerCTX, schemaType) + createResp := instance.CreateUserSchemaEmptyWithType(isolatedIAMOwnerCTX, schemaType) request.Filters = []*schema.SearchFilter{ { Filter: &schema.SearchFilter_IdFilter{ @@ -121,14 +124,14 @@ func TestServer_ListUserSchemas(t *testing.T) { { name: "multiple (type), ok", args: args{ - ctx: IAMOwnerCTX, + ctx: isolatedIAMOwnerCTX, req: &schema.SearchUserSchemasRequest{}, prepare: func(request *schema.SearchUserSchemasRequest, resp *schema.SearchUserSchemasResponse) error { schemaType := gofakeit.Name() schemaType1 := schemaType + "_1" schemaType2 := schemaType + "_2" - createResp := Instance.CreateUserSchemaEmptyWithType(IAMOwnerCTX, schemaType1) - createResp2 := Instance.CreateUserSchemaEmptyWithType(IAMOwnerCTX, schemaType2) + createResp := instance.CreateUserSchemaEmptyWithType(isolatedIAMOwnerCTX, schemaType1) + createResp2 := instance.CreateUserSchemaEmptyWithType(isolatedIAMOwnerCTX, schemaType2) request.SortingColumn = gu.Ptr(schema.FieldName_FIELD_NAME_TYPE) request.Query = &object.SearchQuery{Desc: false} @@ -186,12 +189,12 @@ func TestServer_ListUserSchemas(t *testing.T) { } retryDuration := 20 * time.Second - if ctxDeadline, ok := IAMOwnerCTX.Deadline(); ok { + if ctxDeadline, ok := isolatedIAMOwnerCTX.Deadline(); ok { retryDuration = time.Until(ctxDeadline) } require.EventuallyWithT(t, func(ttt *assert.CollectT) { - got, err := Client.SearchUserSchemas(tt.args.ctx, tt.args.req) + got, err := instance.Client.UserSchemaV3.SearchUserSchemas(tt.args.ctx, tt.args.req) if tt.wantErr { require.Error(ttt, err) return @@ -215,7 +218,10 @@ func TestServer_ListUserSchemas(t *testing.T) { } func TestServer_GetUserSchema(t *testing.T) { - ensureFeatureEnabled(t, IAMOwnerCTX) + t.Parallel() + instance := integration.NewInstance(CTX) + ensureFeatureEnabled(t, instance) + isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) userSchema := new(structpb.Struct) err := userSchema.UnmarshalJSON([]byte(`{ @@ -238,11 +244,11 @@ func TestServer_GetUserSchema(t *testing.T) { { name: "missing permission", args: args{ - ctx: Instance.WithAuthorization(context.Background(), integration.UserTypeOrgOwner), + ctx: instance.WithAuthorization(context.Background(), integration.UserTypeOrgOwner), req: &schema.GetUserSchemaRequest{}, prepare: func(request *schema.GetUserSchemaRequest, resp *schema.GetUserSchemaResponse) error { schemaType := gofakeit.Name() - createResp := Instance.CreateUserSchemaEmptyWithType(IAMOwnerCTX, schemaType) + createResp := instance.CreateUserSchemaEmptyWithType(isolatedIAMOwnerCTX, schemaType) request.Id = createResp.GetDetails().GetId() return nil }, @@ -252,7 +258,7 @@ func TestServer_GetUserSchema(t *testing.T) { { name: "not existing, error", args: args{ - ctx: IAMOwnerCTX, + ctx: isolatedIAMOwnerCTX, req: &schema.GetUserSchemaRequest{ Id: "notexisting", }, @@ -262,11 +268,11 @@ func TestServer_GetUserSchema(t *testing.T) { { name: "get, ok", args: args{ - ctx: IAMOwnerCTX, + ctx: isolatedIAMOwnerCTX, req: &schema.GetUserSchemaRequest{}, prepare: func(request *schema.GetUserSchemaRequest, resp *schema.GetUserSchemaResponse) error { schemaType := gofakeit.Name() - createResp := Instance.CreateUserSchemaEmptyWithType(IAMOwnerCTX, schemaType) + createResp := instance.CreateUserSchemaEmptyWithType(isolatedIAMOwnerCTX, schemaType) request.Id = createResp.GetDetails().GetId() resp.UserSchema.Config.Type = schemaType @@ -295,12 +301,12 @@ func TestServer_GetUserSchema(t *testing.T) { } retryDuration := 5 * time.Second - if ctxDeadline, ok := IAMOwnerCTX.Deadline(); ok { + if ctxDeadline, ok := isolatedIAMOwnerCTX.Deadline(); ok { retryDuration = time.Until(ctxDeadline) } require.EventuallyWithT(t, func(ttt *assert.CollectT) { - got, err := Client.GetUserSchema(tt.args.ctx, tt.args.req) + got, err := instance.Client.UserSchemaV3.GetUserSchema(tt.args.ctx, tt.args.req) if tt.wantErr { assert.Error(t, err, "Error: "+err.Error()) } else { diff --git a/internal/api/grpc/resources/userschema/v3alpha/integration_test/server_test.go b/internal/api/grpc/resources/userschema/v3alpha/integration_test/server_test.go index fbdef9bee8..f779a98d87 100644 --- a/internal/api/grpc/resources/userschema/v3alpha/integration_test/server_test.go +++ b/internal/api/grpc/resources/userschema/v3alpha/integration_test/server_test.go @@ -14,49 +14,41 @@ import ( "github.com/zitadel/zitadel/internal/integration" "github.com/zitadel/zitadel/pkg/grpc/feature/v2" - schema "github.com/zitadel/zitadel/pkg/grpc/resources/userschema/v3alpha" ) var ( - IAMOwnerCTX, SystemCTX context.Context - Instance *integration.Instance - Client schema.ZITADELUserSchemasClient + CTX context.Context ) func TestMain(m *testing.M) { os.Exit(func() int { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) defer cancel() - - Instance = integration.NewInstance(ctx) - - IAMOwnerCTX = Instance.WithAuthorization(ctx, integration.UserTypeIAMOwner) - SystemCTX = integration.WithSystemAuthorization(ctx) - Client = Instance.Client.UserSchemaV3 - + CTX = ctx return m.Run() }()) } -func ensureFeatureEnabled(t *testing.T, iamOwnerCTX context.Context) { - f, err := Instance.Client.FeatureV2.GetInstanceFeatures(iamOwnerCTX, &feature.GetInstanceFeaturesRequest{ +func ensureFeatureEnabled(t *testing.T, instance *integration.Instance) { + ctx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + f, err := instance.Client.FeatureV2.GetInstanceFeatures(ctx, &feature.GetInstanceFeaturesRequest{ Inheritance: true, }) require.NoError(t, err) if f.UserSchema.GetEnabled() { return } - _, err = Instance.Client.FeatureV2.SetInstanceFeatures(iamOwnerCTX, &feature.SetInstanceFeaturesRequest{ + _, err = instance.Client.FeatureV2.SetInstanceFeatures(ctx, &feature.SetInstanceFeaturesRequest{ UserSchema: gu.Ptr(true), }) require.NoError(t, err) retryDuration := time.Minute - if ctxDeadline, ok := iamOwnerCTX.Deadline(); ok { + if ctxDeadline, ok := ctx.Deadline(); ok { retryDuration = time.Until(ctxDeadline) } require.EventuallyWithT(t, func(ttt *assert.CollectT) { - f, err := Instance.Client.FeatureV2.GetInstanceFeatures(iamOwnerCTX, &feature.GetInstanceFeaturesRequest{ + f, err := instance.Client.FeatureV2.GetInstanceFeatures(ctx, &feature.GetInstanceFeaturesRequest{ Inheritance: true, }) require.NoError(ttt, err) diff --git a/internal/api/grpc/resources/userschema/v3alpha/integration_test/userschema_test.go b/internal/api/grpc/resources/userschema/v3alpha/integration_test/userschema_test.go index f7216f8426..a264b163eb 100644 --- a/internal/api/grpc/resources/userschema/v3alpha/integration_test/userschema_test.go +++ b/internal/api/grpc/resources/userschema/v3alpha/integration_test/userschema_test.go @@ -19,7 +19,10 @@ import ( ) func TestServer_CreateUserSchema(t *testing.T) { - ensureFeatureEnabled(t, IAMOwnerCTX) + t.Parallel() + instance := integration.NewInstance(CTX) + ensureFeatureEnabled(t, instance) + isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) tests := []struct { name string @@ -30,7 +33,7 @@ func TestServer_CreateUserSchema(t *testing.T) { }{ { name: "missing permission, error", - ctx: Instance.WithAuthorization(context.Background(), integration.UserTypeOrgOwner), + ctx: instance.WithAuthorization(context.Background(), integration.UserTypeOrgOwner), req: &schema.CreateUserSchemaRequest{ UserSchema: &schema.UserSchema{ Type: gofakeit.Name(), @@ -40,7 +43,7 @@ func TestServer_CreateUserSchema(t *testing.T) { }, { name: "empty type", - ctx: IAMOwnerCTX, + ctx: isolatedIAMOwnerCTX, req: &schema.CreateUserSchemaRequest{ UserSchema: &schema.UserSchema{ Type: "", @@ -50,7 +53,7 @@ func TestServer_CreateUserSchema(t *testing.T) { }, { name: "empty schema, error", - ctx: IAMOwnerCTX, + ctx: isolatedIAMOwnerCTX, req: &schema.CreateUserSchemaRequest{ UserSchema: &schema.UserSchema{ Type: gofakeit.Name(), @@ -60,7 +63,7 @@ func TestServer_CreateUserSchema(t *testing.T) { }, { name: "invalid schema, error", - ctx: IAMOwnerCTX, + ctx: isolatedIAMOwnerCTX, req: &schema.CreateUserSchemaRequest{ UserSchema: &schema.UserSchema{ Type: gofakeit.Name(), @@ -91,7 +94,7 @@ func TestServer_CreateUserSchema(t *testing.T) { }, { name: "no authenticators, ok", - ctx: IAMOwnerCTX, + ctx: isolatedIAMOwnerCTX, req: &schema.CreateUserSchemaRequest{ UserSchema: &schema.UserSchema{ Type: gofakeit.Name(), @@ -123,14 +126,14 @@ func TestServer_CreateUserSchema(t *testing.T) { Changed: timestamppb.Now(), Owner: &object.Owner{ Type: object.OwnerType_OWNER_TYPE_INSTANCE, - Id: Instance.ID(), + Id: instance.ID(), }, }, }, }, { name: "invalid authenticator, error", - ctx: IAMOwnerCTX, + ctx: isolatedIAMOwnerCTX, req: &schema.CreateUserSchemaRequest{ UserSchema: &schema.UserSchema{ Type: gofakeit.Name(), @@ -164,7 +167,7 @@ func TestServer_CreateUserSchema(t *testing.T) { }, { name: "with authenticator, ok", - ctx: IAMOwnerCTX, + ctx: isolatedIAMOwnerCTX, req: &schema.CreateUserSchemaRequest{ UserSchema: &schema.UserSchema{ Type: gofakeit.Name(), @@ -199,14 +202,14 @@ func TestServer_CreateUserSchema(t *testing.T) { Changed: timestamppb.Now(), Owner: &object.Owner{ Type: object.OwnerType_OWNER_TYPE_INSTANCE, - Id: Instance.ID(), + Id: instance.ID(), }, }, }, }, { name: "with invalid permission, error", - ctx: IAMOwnerCTX, + ctx: isolatedIAMOwnerCTX, req: &schema.CreateUserSchemaRequest{ UserSchema: &schema.UserSchema{ Type: gofakeit.Name(), @@ -241,7 +244,7 @@ func TestServer_CreateUserSchema(t *testing.T) { }, { name: "with valid permission, ok", - ctx: IAMOwnerCTX, + ctx: isolatedIAMOwnerCTX, req: &schema.CreateUserSchemaRequest{ UserSchema: &schema.UserSchema{ Type: gofakeit.Name(), @@ -280,7 +283,7 @@ func TestServer_CreateUserSchema(t *testing.T) { Changed: timestamppb.Now(), Owner: &object.Owner{ Type: object.OwnerType_OWNER_TYPE_INSTANCE, - Id: Instance.ID(), + Id: instance.ID(), }, }, }, @@ -288,7 +291,7 @@ func TestServer_CreateUserSchema(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := Client.CreateUserSchema(tt.ctx, tt.req) + got, err := instance.Client.UserSchemaV3.CreateUserSchema(tt.ctx, tt.req) if tt.wantErr { require.Error(t, err) return @@ -301,7 +304,10 @@ func TestServer_CreateUserSchema(t *testing.T) { } func TestServer_UpdateUserSchema(t *testing.T) { - ensureFeatureEnabled(t, IAMOwnerCTX) + t.Parallel() + instance := integration.NewInstance(CTX) + ensureFeatureEnabled(t, instance) + isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) type args struct { ctx context.Context @@ -317,12 +323,12 @@ func TestServer_UpdateUserSchema(t *testing.T) { { name: "missing permission, error", prepare: func(request *schema.PatchUserSchemaRequest) error { - schemaID := Instance.CreateUserSchemaEmpty(IAMOwnerCTX).GetDetails().GetId() + schemaID := instance.CreateUserSchemaEmpty(isolatedIAMOwnerCTX).GetDetails().GetId() request.Id = schemaID return nil }, args: args{ - ctx: Instance.WithAuthorization(context.Background(), integration.UserTypeOrgOwner), + ctx: instance.WithAuthorization(context.Background(), integration.UserTypeOrgOwner), req: &schema.PatchUserSchemaRequest{ UserSchema: &schema.PatchUserSchema{ Type: gu.Ptr(gofakeit.Name()), @@ -337,7 +343,7 @@ func TestServer_UpdateUserSchema(t *testing.T) { return nil }, args: args{ - ctx: IAMOwnerCTX, + ctx: isolatedIAMOwnerCTX, req: &schema.PatchUserSchemaRequest{}, }, wantErr: true, @@ -349,7 +355,7 @@ func TestServer_UpdateUserSchema(t *testing.T) { return nil }, args: args{ - ctx: IAMOwnerCTX, + ctx: isolatedIAMOwnerCTX, req: &schema.PatchUserSchemaRequest{}, }, wantErr: true, @@ -357,12 +363,12 @@ func TestServer_UpdateUserSchema(t *testing.T) { { name: "empty type, error", prepare: func(request *schema.PatchUserSchemaRequest) error { - schemaID := Instance.CreateUserSchemaEmpty(IAMOwnerCTX).GetDetails().GetId() + schemaID := instance.CreateUserSchemaEmpty(isolatedIAMOwnerCTX).GetDetails().GetId() request.Id = schemaID return nil }, args: args{ - ctx: IAMOwnerCTX, + ctx: isolatedIAMOwnerCTX, req: &schema.PatchUserSchemaRequest{ UserSchema: &schema.PatchUserSchema{ Type: gu.Ptr(""), @@ -374,12 +380,12 @@ func TestServer_UpdateUserSchema(t *testing.T) { { name: "update type, ok", prepare: func(request *schema.PatchUserSchemaRequest) error { - schemaID := Instance.CreateUserSchemaEmpty(IAMOwnerCTX).GetDetails().GetId() + schemaID := instance.CreateUserSchemaEmpty(isolatedIAMOwnerCTX).GetDetails().GetId() request.Id = schemaID return nil }, args: args{ - ctx: IAMOwnerCTX, + ctx: isolatedIAMOwnerCTX, req: &schema.PatchUserSchemaRequest{ UserSchema: &schema.PatchUserSchema{ Type: gu.Ptr(gofakeit.Name()), @@ -391,7 +397,7 @@ func TestServer_UpdateUserSchema(t *testing.T) { Changed: timestamppb.Now(), Owner: &object.Owner{ Type: object.OwnerType_OWNER_TYPE_INSTANCE, - Id: Instance.ID(), + Id: instance.ID(), }, }, }, @@ -399,12 +405,12 @@ func TestServer_UpdateUserSchema(t *testing.T) { { name: "empty schema, ok", prepare: func(request *schema.PatchUserSchemaRequest) error { - schemaID := Instance.CreateUserSchemaEmpty(IAMOwnerCTX).GetDetails().GetId() + schemaID := instance.CreateUserSchemaEmpty(isolatedIAMOwnerCTX).GetDetails().GetId() request.Id = schemaID return nil }, args: args{ - ctx: IAMOwnerCTX, + ctx: isolatedIAMOwnerCTX, req: &schema.PatchUserSchemaRequest{ UserSchema: &schema.PatchUserSchema{ DataType: &schema.PatchUserSchema_Schema{}, @@ -416,7 +422,7 @@ func TestServer_UpdateUserSchema(t *testing.T) { Changed: timestamppb.Now(), Owner: &object.Owner{ Type: object.OwnerType_OWNER_TYPE_INSTANCE, - Id: Instance.ID(), + Id: instance.ID(), }, }, }, @@ -424,12 +430,12 @@ func TestServer_UpdateUserSchema(t *testing.T) { { name: "invalid schema, error", prepare: func(request *schema.PatchUserSchemaRequest) error { - schemaID := Instance.CreateUserSchemaEmpty(IAMOwnerCTX).GetDetails().GetId() + schemaID := instance.CreateUserSchemaEmpty(isolatedIAMOwnerCTX).GetDetails().GetId() request.Id = schemaID return nil }, args: args{ - ctx: IAMOwnerCTX, + ctx: isolatedIAMOwnerCTX, req: &schema.PatchUserSchemaRequest{ UserSchema: &schema.PatchUserSchema{ DataType: &schema.PatchUserSchema_Schema{ @@ -462,12 +468,12 @@ func TestServer_UpdateUserSchema(t *testing.T) { { name: "update schema, ok", prepare: func(request *schema.PatchUserSchemaRequest) error { - schemaID := Instance.CreateUserSchemaEmpty(IAMOwnerCTX).GetDetails().GetId() + schemaID := instance.CreateUserSchemaEmpty(isolatedIAMOwnerCTX).GetDetails().GetId() request.Id = schemaID return nil }, args: args{ - ctx: IAMOwnerCTX, + ctx: isolatedIAMOwnerCTX, req: &schema.PatchUserSchemaRequest{ UserSchema: &schema.PatchUserSchema{ DataType: &schema.PatchUserSchema_Schema{ @@ -500,7 +506,7 @@ func TestServer_UpdateUserSchema(t *testing.T) { Changed: timestamppb.Now(), Owner: &object.Owner{ Type: object.OwnerType_OWNER_TYPE_INSTANCE, - Id: Instance.ID(), + Id: instance.ID(), }, }, }, @@ -508,12 +514,12 @@ func TestServer_UpdateUserSchema(t *testing.T) { { name: "invalid authenticator, error", prepare: func(request *schema.PatchUserSchemaRequest) error { - schemaID := Instance.CreateUserSchemaEmpty(IAMOwnerCTX).GetDetails().GetId() + schemaID := instance.CreateUserSchemaEmpty(isolatedIAMOwnerCTX).GetDetails().GetId() request.Id = schemaID return nil }, args: args{ - ctx: IAMOwnerCTX, + ctx: isolatedIAMOwnerCTX, req: &schema.PatchUserSchemaRequest{ UserSchema: &schema.PatchUserSchema{ PossibleAuthenticators: []schema.AuthenticatorType{ @@ -527,12 +533,12 @@ func TestServer_UpdateUserSchema(t *testing.T) { { name: "update authenticator, ok", prepare: func(request *schema.PatchUserSchemaRequest) error { - schemaID := Instance.CreateUserSchemaEmpty(IAMOwnerCTX).GetDetails().GetId() + schemaID := instance.CreateUserSchemaEmpty(isolatedIAMOwnerCTX).GetDetails().GetId() request.Id = schemaID return nil }, args: args{ - ctx: IAMOwnerCTX, + ctx: isolatedIAMOwnerCTX, req: &schema.PatchUserSchemaRequest{ UserSchema: &schema.PatchUserSchema{ PossibleAuthenticators: []schema.AuthenticatorType{ @@ -546,7 +552,7 @@ func TestServer_UpdateUserSchema(t *testing.T) { Changed: timestamppb.Now(), Owner: &object.Owner{ Type: object.OwnerType_OWNER_TYPE_INSTANCE, - Id: Instance.ID(), + Id: instance.ID(), }, }, }, @@ -554,8 +560,8 @@ func TestServer_UpdateUserSchema(t *testing.T) { { name: "inactive, error", prepare: func(request *schema.PatchUserSchemaRequest) error { - schemaID := Instance.CreateUserSchemaEmpty(IAMOwnerCTX).GetDetails().GetId() - _, err := Client.DeactivateUserSchema(IAMOwnerCTX, &schema.DeactivateUserSchemaRequest{ + schemaID := instance.CreateUserSchemaEmpty(isolatedIAMOwnerCTX).GetDetails().GetId() + _, err := instance.Client.UserSchemaV3.DeactivateUserSchema(isolatedIAMOwnerCTX, &schema.DeactivateUserSchemaRequest{ Id: schemaID, }) require.NoError(t, err) @@ -563,7 +569,7 @@ func TestServer_UpdateUserSchema(t *testing.T) { return nil }, args: args{ - ctx: IAMOwnerCTX, + ctx: isolatedIAMOwnerCTX, req: &schema.PatchUserSchemaRequest{ UserSchema: &schema.PatchUserSchema{ Type: gu.Ptr(gofakeit.Name()), @@ -578,7 +584,7 @@ func TestServer_UpdateUserSchema(t *testing.T) { err := tt.prepare(tt.args.req) require.NoError(t, err) - got, err := Client.PatchUserSchema(tt.args.ctx, tt.args.req) + got, err := instance.Client.UserSchemaV3.PatchUserSchema(tt.args.ctx, tt.args.req) if tt.wantErr { require.Error(t, err) return @@ -590,7 +596,10 @@ func TestServer_UpdateUserSchema(t *testing.T) { } func TestServer_DeactivateUserSchema(t *testing.T) { - ensureFeatureEnabled(t, IAMOwnerCTX) + t.Parallel() + instance := integration.NewInstance(CTX) + ensureFeatureEnabled(t, instance) + isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) type args struct { ctx context.Context @@ -606,7 +615,7 @@ func TestServer_DeactivateUserSchema(t *testing.T) { { name: "not existing, error", args: args{ - IAMOwnerCTX, + isolatedIAMOwnerCTX, &schema.DeactivateUserSchemaRequest{ Id: "notexisting", }, @@ -617,10 +626,10 @@ func TestServer_DeactivateUserSchema(t *testing.T) { { name: "active, ok", args: args{ - IAMOwnerCTX, + isolatedIAMOwnerCTX, &schema.DeactivateUserSchemaRequest{}, func(request *schema.DeactivateUserSchemaRequest) error { - schemaID := Instance.CreateUserSchemaEmpty(IAMOwnerCTX).GetDetails().GetId() + schemaID := instance.CreateUserSchemaEmpty(isolatedIAMOwnerCTX).GetDetails().GetId() request.Id = schemaID return nil }, @@ -630,7 +639,7 @@ func TestServer_DeactivateUserSchema(t *testing.T) { Changed: timestamppb.Now(), Owner: &object.Owner{ Type: object.OwnerType_OWNER_TYPE_INSTANCE, - Id: Instance.ID(), + Id: instance.ID(), }, }, }, @@ -638,12 +647,12 @@ func TestServer_DeactivateUserSchema(t *testing.T) { { name: "inactive, error", args: args{ - IAMOwnerCTX, + isolatedIAMOwnerCTX, &schema.DeactivateUserSchemaRequest{}, func(request *schema.DeactivateUserSchemaRequest) error { - schemaID := Instance.CreateUserSchemaEmpty(IAMOwnerCTX).GetDetails().GetId() + schemaID := instance.CreateUserSchemaEmpty(isolatedIAMOwnerCTX).GetDetails().GetId() request.Id = schemaID - _, err := Client.DeactivateUserSchema(IAMOwnerCTX, &schema.DeactivateUserSchemaRequest{ + _, err := instance.Client.UserSchemaV3.DeactivateUserSchema(isolatedIAMOwnerCTX, &schema.DeactivateUserSchemaRequest{ Id: schemaID, }) return err @@ -657,7 +666,7 @@ func TestServer_DeactivateUserSchema(t *testing.T) { err := tt.args.prepare(tt.args.req) require.NoError(t, err) - got, err := Client.DeactivateUserSchema(tt.args.ctx, tt.args.req) + got, err := instance.Client.UserSchemaV3.DeactivateUserSchema(tt.args.ctx, tt.args.req) if tt.wantErr { require.Error(t, err) return @@ -669,7 +678,10 @@ func TestServer_DeactivateUserSchema(t *testing.T) { } func TestServer_ReactivateUserSchema(t *testing.T) { - ensureFeatureEnabled(t, IAMOwnerCTX) + t.Parallel() + instance := integration.NewInstance(CTX) + ensureFeatureEnabled(t, instance) + isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) type args struct { ctx context.Context @@ -685,7 +697,7 @@ func TestServer_ReactivateUserSchema(t *testing.T) { { name: "not existing, error", args: args{ - IAMOwnerCTX, + isolatedIAMOwnerCTX, &schema.ReactivateUserSchemaRequest{ Id: "notexisting", }, @@ -696,10 +708,10 @@ func TestServer_ReactivateUserSchema(t *testing.T) { { name: "active, error", args: args{ - ctx: IAMOwnerCTX, + ctx: isolatedIAMOwnerCTX, req: &schema.ReactivateUserSchemaRequest{}, prepare: func(request *schema.ReactivateUserSchemaRequest) error { - schemaID := Instance.CreateUserSchemaEmpty(IAMOwnerCTX).GetDetails().GetId() + schemaID := instance.CreateUserSchemaEmpty(isolatedIAMOwnerCTX).GetDetails().GetId() request.Id = schemaID return nil }, @@ -709,12 +721,12 @@ func TestServer_ReactivateUserSchema(t *testing.T) { { name: "inactive, ok", args: args{ - ctx: IAMOwnerCTX, + ctx: isolatedIAMOwnerCTX, req: &schema.ReactivateUserSchemaRequest{}, prepare: func(request *schema.ReactivateUserSchemaRequest) error { - schemaID := Instance.CreateUserSchemaEmpty(IAMOwnerCTX).GetDetails().GetId() + schemaID := instance.CreateUserSchemaEmpty(isolatedIAMOwnerCTX).GetDetails().GetId() request.Id = schemaID - _, err := Client.DeactivateUserSchema(IAMOwnerCTX, &schema.DeactivateUserSchemaRequest{ + _, err := instance.Client.UserSchemaV3.DeactivateUserSchema(isolatedIAMOwnerCTX, &schema.DeactivateUserSchemaRequest{ Id: schemaID, }) return err @@ -725,7 +737,7 @@ func TestServer_ReactivateUserSchema(t *testing.T) { Changed: timestamppb.Now(), Owner: &object.Owner{ Type: object.OwnerType_OWNER_TYPE_INSTANCE, - Id: Instance.ID(), + Id: instance.ID(), }, }, }, @@ -736,7 +748,7 @@ func TestServer_ReactivateUserSchema(t *testing.T) { err := tt.args.prepare(tt.args.req) require.NoError(t, err) - got, err := Client.ReactivateUserSchema(tt.args.ctx, tt.args.req) + got, err := instance.Client.UserSchemaV3.ReactivateUserSchema(tt.args.ctx, tt.args.req) if tt.wantErr { require.Error(t, err) return @@ -748,7 +760,10 @@ func TestServer_ReactivateUserSchema(t *testing.T) { } func TestServer_DeleteUserSchema(t *testing.T) { - ensureFeatureEnabled(t, IAMOwnerCTX) + t.Parallel() + instance := integration.NewInstance(CTX) + ensureFeatureEnabled(t, instance) + isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) type args struct { ctx context.Context @@ -764,7 +779,7 @@ func TestServer_DeleteUserSchema(t *testing.T) { { name: "not existing, error", args: args{ - IAMOwnerCTX, + isolatedIAMOwnerCTX, &schema.DeleteUserSchemaRequest{ Id: "notexisting", }, @@ -775,10 +790,10 @@ func TestServer_DeleteUserSchema(t *testing.T) { { name: "delete, ok", args: args{ - ctx: IAMOwnerCTX, + ctx: isolatedIAMOwnerCTX, req: &schema.DeleteUserSchemaRequest{}, prepare: func(request *schema.DeleteUserSchemaRequest) error { - schemaID := Instance.CreateUserSchemaEmpty(IAMOwnerCTX).GetDetails().GetId() + schemaID := instance.CreateUserSchemaEmpty(isolatedIAMOwnerCTX).GetDetails().GetId() request.Id = schemaID return nil }, @@ -788,7 +803,7 @@ func TestServer_DeleteUserSchema(t *testing.T) { Changed: timestamppb.Now(), Owner: &object.Owner{ Type: object.OwnerType_OWNER_TYPE_INSTANCE, - Id: Instance.ID(), + Id: instance.ID(), }, }, }, @@ -796,12 +811,12 @@ func TestServer_DeleteUserSchema(t *testing.T) { { name: "deleted, error", args: args{ - ctx: IAMOwnerCTX, + ctx: isolatedIAMOwnerCTX, req: &schema.DeleteUserSchemaRequest{}, prepare: func(request *schema.DeleteUserSchemaRequest) error { - schemaID := Instance.CreateUserSchemaEmpty(IAMOwnerCTX).GetDetails().GetId() + schemaID := instance.CreateUserSchemaEmpty(isolatedIAMOwnerCTX).GetDetails().GetId() request.Id = schemaID - _, err := Client.DeleteUserSchema(IAMOwnerCTX, &schema.DeleteUserSchemaRequest{ + _, err := instance.Client.UserSchemaV3.DeleteUserSchema(isolatedIAMOwnerCTX, &schema.DeleteUserSchemaRequest{ Id: schemaID, }) return err @@ -815,7 +830,7 @@ func TestServer_DeleteUserSchema(t *testing.T) { err := tt.args.prepare(tt.args.req) require.NoError(t, err) - got, err := Client.DeleteUserSchema(tt.args.ctx, tt.args.req) + got, err := instance.Client.UserSchemaV3.DeleteUserSchema(tt.args.ctx, tt.args.req) if tt.wantErr { require.Error(t, err) return diff --git a/internal/command/user_schema.go b/internal/command/user_schema.go index 7b53c9e165..3120071d55 100644 --- a/internal/command/user_schema.go +++ b/internal/command/user_schema.go @@ -186,7 +186,7 @@ func validateUserSchema(userSchema json.RawMessage) error { } func (c *Commands) getSchemaWriteModelByID(ctx context.Context, resourceOwner, id string) (*UserSchemaWriteModel, error) { - writeModel := NewUserSchemaWriteModel(resourceOwner, id, "") + writeModel := NewUserSchemaWriteModel(resourceOwner, id) if err := c.eventstore.FilterToQueryReducer(ctx, writeModel); err != nil { return nil, err } diff --git a/internal/command/user_schema_model.go b/internal/command/user_schema_model.go index e8df80479a..08352e6d57 100644 --- a/internal/command/user_schema_model.go +++ b/internal/command/user_schema_model.go @@ -19,16 +19,15 @@ type UserSchemaWriteModel struct { Schema json.RawMessage PossibleAuthenticators []domain.AuthenticatorType State domain.UserSchemaState - Revision uint64 + SchemaRevision uint64 } -func NewUserSchemaWriteModel(resourceOwner, schemaID, ty string) *UserSchemaWriteModel { +func NewUserSchemaWriteModel(resourceOwner, schemaID string) *UserSchemaWriteModel { return &UserSchemaWriteModel{ WriteModel: eventstore.WriteModel{ AggregateID: schemaID, ResourceOwner: resourceOwner, }, - SchemaType: ty, } } @@ -40,13 +39,13 @@ func (wm *UserSchemaWriteModel) Reduce() error { wm.Schema = e.Schema wm.PossibleAuthenticators = e.PossibleAuthenticators wm.State = domain.UserSchemaStateActive - wm.Revision = 1 + wm.SchemaRevision = 1 case *schema.UpdatedEvent: if e.SchemaType != nil { wm.SchemaType = *e.SchemaType } if e.SchemaRevision != nil { - wm.Revision = *e.SchemaRevision + wm.SchemaRevision = *e.SchemaRevision } if len(e.Schema) > 0 { wm.Schema = e.Schema @@ -79,10 +78,6 @@ func (wm *UserSchemaWriteModel) Query() *eventstore.SearchQueryBuilder { schema.DeletedType, ) - if wm.SchemaType != "" { - query = query.EventData(map[string]interface{}{"schemaType": wm.SchemaType}) - } - return query.Builder() } func (wm *UserSchemaWriteModel) NewUpdatedEvent( @@ -99,7 +94,7 @@ func (wm *UserSchemaWriteModel) NewUpdatedEvent( if !bytes.Equal(wm.Schema, userSchema) { changes = append(changes, schema.ChangeSchema(userSchema)) // change revision if the content of the schema changed - changes = append(changes, schema.IncreaseRevision(wm.Revision)) + changes = append(changes, schema.IncreaseRevision(wm.SchemaRevision)) } if len(possibleAuthenticators) > 0 && slices.Compare(wm.PossibleAuthenticators, possibleAuthenticators) != 0 { changes = append(changes, schema.ChangePossibleAuthenticators(possibleAuthenticators)) diff --git a/internal/command/user_v3.go b/internal/command/user_v3.go index f2cacd6cb0..d5a097ce27 100644 --- a/internal/command/user_v3.go +++ b/internal/command/user_v3.go @@ -15,14 +15,14 @@ import ( ) type CreateSchemaUser struct { - Details *domain.ObjectDetails - ResourceOwner string + Details *domain.ObjectDetails SchemaID string schemaRevision uint64 - ID string - Data json.RawMessage + ResourceOwner string + ID string + Data json.RawMessage Email *Email ReturnCodeEmail string @@ -45,7 +45,7 @@ func (s *CreateSchemaUser) Valid(ctx context.Context, c *Commands) (err error) { if !schemaWriteModel.Exists() { return zerrors.ThrowPreconditionFailed(nil, "COMMAND-N9QOuN4F7o", "Errors.UserSchema.NotExists") } - s.schemaRevision = schemaWriteModel.Revision + s.schemaRevision = schemaWriteModel.SchemaRevision if s.ID == "" { s.ID, err = c.idGenerator.Next() @@ -120,13 +120,13 @@ func (c *Commands) CreateSchemaUser(ctx context.Context, user *CreateSchemaUser, ), } if user.Email != nil { - events, user.ReturnCodeEmail, err = c.updateSchemaUserEmail(ctx, events, userAgg, user.Email, alg) + events, user.ReturnCodeEmail, err = c.updateSchemaUserEmail(ctx, writeModel, events, userAgg, user.Email, alg) if err != nil { return err } } if user.Phone != nil { - events, user.ReturnCodePhone, err = c.updateSchemaUserPhone(ctx, events, userAgg, user.Phone, alg) + events, user.ReturnCodePhone, err = c.updateSchemaUserPhone(ctx, writeModel, events, userAgg, user.Phone, alg) if err != nil { return err } @@ -139,11 +139,11 @@ func (c *Commands) CreateSchemaUser(ctx context.Context, user *CreateSchemaUser, return nil } -func (c *Commands) DeleteSchemaUser(ctx context.Context, id string) (*domain.ObjectDetails, error) { +func (c *Commands) DeleteSchemaUser(ctx context.Context, resourceOwner, id string) (*domain.ObjectDetails, error) { if id == "" { return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-Vs4wJCME7T", "Errors.IDMissing") } - writeModel, err := c.getSchemaUserExists(ctx, "", id) + writeModel, err := c.getSchemaUserExists(ctx, resourceOwner, id) if err != nil { return nil, err } @@ -161,7 +161,235 @@ func (c *Commands) DeleteSchemaUser(ctx context.Context, id string) (*domain.Obj return writeModelToObjectDetails(&writeModel.WriteModel), nil } -func (c *Commands) updateSchemaUserEmail(ctx context.Context, events []eventstore.Command, agg *eventstore.Aggregate, email *Email, alg crypto.EncryptionAlgorithm) (_ []eventstore.Command, plainCode string, err error) { +type ChangeSchemaUser struct { + Details *domain.ObjectDetails + + SchemaID *string + schemaWriteModel *UserSchemaWriteModel + + ResourceOwner string + ID string + Data json.RawMessage + + Email *Email + ReturnCodeEmail string + Phone *Phone + ReturnCodePhone string +} + +func (s *ChangeSchemaUser) Valid(ctx context.Context, c *Commands) (err error) { + if s.ID == "" { + return zerrors.ThrowInvalidArgument(nil, "COMMAND-gEJR1QOGHb", "Errors.IDMissing") + } + if s.SchemaID != nil { + s.schemaWriteModel, err = c.getSchemaWriteModelByID(ctx, "", *s.SchemaID) + if err != nil { + return err + } + if !s.schemaWriteModel.Exists() { + return zerrors.ThrowPreconditionFailed(nil, "COMMAND-VLDTtxT3If", "Errors.UserSchema.NotExists") + } + } + + if s.Email != nil && s.Email.Address != "" { + if err := s.Email.Validate(); err != nil { + return err + } + } + + if s.Phone != nil && s.Phone.Number != "" { + if s.Phone.Number, err = s.Phone.Number.Normalize(); err != nil { + return err + } + } + + return nil +} + +func (s *ChangeSchemaUser) ValidData(ctx context.Context, c *Commands, existingUser *UserV3WriteModel) (err error) { + // get role for permission check in schema through extension + role, err := c.getSchemaRoleForWrite(ctx, existingUser.ResourceOwner, existingUser.AggregateID) + if err != nil { + return err + } + + if s.schemaWriteModel == nil { + s.schemaWriteModel, err = c.getSchemaWriteModelByID(ctx, "", existingUser.SchemaID) + if err != nil { + return err + } + } + + schema, err := domain_schema.NewSchema(role, bytes.NewReader(s.schemaWriteModel.Schema)) + if err != nil { + return err + } + + // if data not changed but a new schema or revision should be used + data := s.Data + if s.Data == nil { + data = existingUser.Data + } + + var v interface{} + if err := json.Unmarshal(data, &v); err != nil { + return zerrors.ThrowInvalidArgument(nil, "COMMAND-7o3ZGxtXUz", "Errors.User.Invalid") + } + + if err := schema.Validate(v); err != nil { + return zerrors.ThrowPreconditionFailed(nil, "COMMAND-SlKXqLSeL6", "Errors.UserSchema.Data.Invalid") + } + return nil +} + +func (c *Commands) ChangeSchemaUser(ctx context.Context, user *ChangeSchemaUser, alg crypto.EncryptionAlgorithm) (err error) { + if err := user.Valid(ctx, c); err != nil { + return err + } + + writeModel, err := c.getSchemaUserWriteModelByID(ctx, user.ResourceOwner, user.ID) + if err != nil { + return err + } + if !writeModel.Exists() { + return zerrors.ThrowPreconditionFailed(nil, "COMMAND-Nn8CRVlkeZ", "Errors.User.NotFound") + } + + userAgg := UserV3AggregateFromWriteModel(&writeModel.WriteModel) + events := make([]eventstore.Command, 0) + if user.Data != nil || user.SchemaID != nil { + if err := user.ValidData(ctx, c, writeModel); err != nil { + return err + } + updateEvent := writeModel.NewUpdatedEvent(ctx, + userAgg, + user.schemaWriteModel.AggregateID, + user.schemaWriteModel.SchemaRevision, + user.Data, + ) + if updateEvent != nil { + events = append(events, updateEvent) + } + } + if user.Email != nil { + events, user.ReturnCodeEmail, err = c.updateSchemaUserEmail(ctx, writeModel, events, userAgg, user.Email, alg) + if err != nil { + return err + } + } + if user.Phone != nil { + events, user.ReturnCodePhone, err = c.updateSchemaUserPhone(ctx, writeModel, events, userAgg, user.Phone, alg) + if err != nil { + return err + } + } + if len(events) == 0 { + user.Details = writeModelToObjectDetails(&writeModel.WriteModel) + return nil + } + if err := c.pushAppendAndReduce(ctx, writeModel, events...); err != nil { + return err + } + user.Details = writeModelToObjectDetails(&writeModel.WriteModel) + return nil +} + +func (c *Commands) checkPermissionUpdateUserState(ctx context.Context, resourceOwner, userID string) error { + return c.checkPermission(ctx, domain.PermissionUserWrite, resourceOwner, userID) +} + +func (c *Commands) LockSchemaUser(ctx context.Context, resourceOwner, id string) (*domain.ObjectDetails, error) { + if id == "" { + return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-Eu8I2VAfjF", "Errors.IDMissing") + } + writeModel, err := c.getSchemaUserExists(ctx, resourceOwner, id) + if err != nil { + return nil, err + } + if !writeModel.Exists() || writeModel.Locked { + return nil, zerrors.ThrowNotFound(nil, "COMMAND-G4LOrnjY7q", "Errors.User.NotFound") + } + if err := c.checkPermissionUpdateUserState(ctx, writeModel.ResourceOwner, writeModel.AggregateID); err != nil { + return nil, err + } + if err := c.pushAppendAndReduce(ctx, writeModel, + schemauser.NewLockedEvent(ctx, UserV3AggregateFromWriteModel(&writeModel.WriteModel)), + ); err != nil { + return nil, err + } + return writeModelToObjectDetails(&writeModel.WriteModel), nil +} + +func (c *Commands) UnlockSchemaUser(ctx context.Context, resourceOwner, id string) (*domain.ObjectDetails, error) { + if id == "" { + return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-krXtYscQZh", "Errors.IDMissing") + } + writeModel, err := c.getSchemaUserExists(ctx, resourceOwner, id) + if err != nil { + return nil, err + } + if !writeModel.Exists() || !writeModel.Locked { + return nil, zerrors.ThrowNotFound(nil, "COMMAND-gpBv46Lh9m", "Errors.User.NotFound") + } + if err := c.checkPermissionUpdateUserState(ctx, writeModel.ResourceOwner, writeModel.AggregateID); err != nil { + return nil, err + } + if err := c.pushAppendAndReduce(ctx, writeModel, + schemauser.NewUnlockedEvent(ctx, UserV3AggregateFromWriteModel(&writeModel.WriteModel)), + ); err != nil { + return nil, err + } + return writeModelToObjectDetails(&writeModel.WriteModel), nil +} + +func (c *Commands) DeactivateSchemaUser(ctx context.Context, resourceOwner, id string) (*domain.ObjectDetails, error) { + if id == "" { + return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-pjJhge86ZV", "Errors.IDMissing") + } + writeModel, err := c.getSchemaUserExists(ctx, resourceOwner, id) + if err != nil { + return nil, err + } + if writeModel.State != domain.UserStateActive { + return nil, zerrors.ThrowNotFound(nil, "COMMAND-Ob6lR5iFTe", "Errors.User.NotFound") + } + if err := c.checkPermissionUpdateUserState(ctx, writeModel.ResourceOwner, writeModel.AggregateID); err != nil { + return nil, err + } + if err := c.pushAppendAndReduce(ctx, writeModel, + schemauser.NewDeactivatedEvent(ctx, UserV3AggregateFromWriteModel(&writeModel.WriteModel)), + ); err != nil { + return nil, err + } + return writeModelToObjectDetails(&writeModel.WriteModel), nil +} + +func (c *Commands) ActivateSchemaUser(ctx context.Context, resourceOwner, id string) (*domain.ObjectDetails, error) { + if id == "" { + return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-17XupGvxBJ", "Errors.IDMissing") + } + writeModel, err := c.getSchemaUserExists(ctx, "", id) + if err != nil { + return nil, err + } + if writeModel.State != domain.UserStateInactive { + return nil, zerrors.ThrowNotFound(nil, "COMMAND-rQjbBr4J3j", "Errors.User.NotFound") + } + if err := c.checkPermissionUpdateUserState(ctx, writeModel.ResourceOwner, writeModel.AggregateID); err != nil { + return nil, err + } + if err := c.pushAppendAndReduce(ctx, writeModel, + schemauser.NewActivatedEvent(ctx, UserV3AggregateFromWriteModel(&writeModel.WriteModel)), + ); err != nil { + return nil, err + } + return writeModelToObjectDetails(&writeModel.WriteModel), nil +} + +func (c *Commands) updateSchemaUserEmail(ctx context.Context, existing *UserV3WriteModel, events []eventstore.Command, agg *eventstore.Aggregate, email *Email, alg crypto.EncryptionAlgorithm) (_ []eventstore.Command, plainCode string, err error) { + if existing.Email == string(email.Address) { + return events, plainCode, nil + } events = append(events, schemauser.NewEmailUpdatedEvent(ctx, agg, @@ -187,8 +415,12 @@ func (c *Commands) updateSchemaUserEmail(ctx context.Context, events []eventstor return events, plainCode, nil } -func (c *Commands) updateSchemaUserPhone(ctx context.Context, events []eventstore.Command, agg *eventstore.Aggregate, phone *Phone, alg crypto.EncryptionAlgorithm) (_ []eventstore.Command, plainCode string, err error) { - events = append(events, schemauser.NewPhoneChangedEvent(ctx, +func (c *Commands) updateSchemaUserPhone(ctx context.Context, existing *UserV3WriteModel, events []eventstore.Command, agg *eventstore.Aggregate, phone *Phone, alg crypto.EncryptionAlgorithm) (_ []eventstore.Command, plainCode string, err error) { + if existing.Phone == string(phone.Number) { + return events, plainCode, nil + } + + events = append(events, schemauser.NewPhoneUpdatedEvent(ctx, agg, phone.Number, )) @@ -218,3 +450,11 @@ func (c *Commands) getSchemaUserExists(ctx context.Context, resourceOwner, id st } return writeModel, nil } + +func (c *Commands) getSchemaUserWriteModelByID(ctx context.Context, resourceOwner, id string) (*UserV3WriteModel, error) { + writeModel := NewUserV3WriteModel(resourceOwner, id) + if err := c.eventstore.FilterToQueryReducer(ctx, writeModel); err != nil { + return nil, err + } + return writeModel, nil +} diff --git a/internal/command/user_v3_model.go b/internal/command/user_v3_model.go index 51f783aaed..6231558178 100644 --- a/internal/command/user_v3_model.go +++ b/internal/command/user_v3_model.go @@ -29,7 +29,8 @@ type UserV3WriteModel struct { Data json.RawMessage - State domain.UserState + Locked bool + State domain.UserState } func NewExistsUserV3WriteModel(resourceOwner, userID string) *UserV3WriteModel { @@ -61,8 +62,9 @@ func (wm *UserV3WriteModel) Reduce() error { switch e := event.(type) { case *schemauser.CreatedEvent: wm.SchemaID = e.SchemaID - wm.SchemaRevision = 1 + wm.SchemaRevision = e.SchemaRevision wm.Data = e.Data + wm.Locked = false wm.State = domain.UserStateActive case *schemauser.UpdatedEvent: @@ -79,6 +81,8 @@ func (wm *UserV3WriteModel) Reduce() error { wm.State = domain.UserStateDeleted case *schemauser.EmailUpdatedEvent: wm.Email = string(e.EmailAddress) + wm.IsEmailVerified = false + wm.EmailVerifiedFailedCount = 0 case *schemauser.EmailCodeAddedEvent: wm.IsEmailVerified = false wm.EmailVerifiedFailedCount = 0 @@ -87,8 +91,10 @@ func (wm *UserV3WriteModel) Reduce() error { wm.EmailVerifiedFailedCount = 0 case *schemauser.EmailVerificationFailedEvent: wm.EmailVerifiedFailedCount += 1 - case *schemauser.PhoneChangedEvent: + case *schemauser.PhoneUpdatedEvent: wm.Phone = string(e.PhoneNumber) + wm.IsPhoneVerified = false + wm.PhoneVerifiedFailedCount = 0 case *schemauser.PhoneCodeAddedEvent: wm.IsPhoneVerified = false wm.PhoneVerifiedFailedCount = 0 @@ -97,28 +103,39 @@ func (wm *UserV3WriteModel) Reduce() error { wm.IsPhoneVerified = true case *schemauser.PhoneVerificationFailedEvent: wm.PhoneVerifiedFailedCount += 1 + case *schemauser.LockedEvent: + wm.Locked = true + case *schemauser.UnlockedEvent: + wm.Locked = false + case *schemauser.DeactivatedEvent: + wm.State = domain.UserStateInactive + case *schemauser.ActivatedEvent: + wm.State = domain.UserStateActive } } return wm.WriteModel.Reduce() } func (wm *UserV3WriteModel) Query() *eventstore.SearchQueryBuilder { - query := eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). - ResourceOwner(wm.ResourceOwner). - AddQuery(). - AggregateTypes(schemauser.AggregateType). - AggregateIDs(wm.AggregateID). - EventTypes( - schemauser.CreatedType, - schemauser.DeletedType, - ) + builder := eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent) + if wm.ResourceOwner != "" { + builder = builder.ResourceOwner(wm.ResourceOwner) + } + eventtypes := []eventstore.EventType{ + schemauser.CreatedType, + schemauser.DeletedType, + schemauser.ActivatedType, + schemauser.DeactivatedType, + schemauser.LockedType, + schemauser.UnlockedType, + } if wm.DataWM { - query = query.EventTypes( + eventtypes = append(eventtypes, schemauser.UpdatedType, ) } if wm.EmailWM { - query = query.EventTypes( + eventtypes = append(eventtypes, schemauser.EmailUpdatedType, schemauser.EmailVerifiedType, schemauser.EmailCodeAddedType, @@ -126,31 +143,34 @@ func (wm *UserV3WriteModel) Query() *eventstore.SearchQueryBuilder { ) } if wm.PhoneWM { - query = query.EventTypes( + eventtypes = append(eventtypes, schemauser.PhoneUpdatedType, schemauser.PhoneVerifiedType, schemauser.PhoneCodeAddedType, schemauser.PhoneVerificationFailedType, ) } - return query.Builder() + return builder.AddQuery(). + AggregateTypes(schemauser.AggregateType). + AggregateIDs(wm.AggregateID). + EventTypes(eventtypes...).Builder() } func (wm *UserV3WriteModel) NewUpdatedEvent( ctx context.Context, agg *eventstore.Aggregate, - schemaID *string, - schemaRevision *uint64, + schemaID string, + schemaRevision uint64, data json.RawMessage, ) *schemauser.UpdatedEvent { changes := make([]schemauser.Changes, 0) - if schemaID != nil && wm.SchemaID != *schemaID { - changes = append(changes, schemauser.ChangeSchemaID(wm.SchemaID, *schemaID)) + if wm.SchemaID != schemaID { + changes = append(changes, schemauser.ChangeSchemaID(schemaID)) } - if schemaRevision != nil && wm.SchemaRevision != *schemaRevision { - changes = append(changes, schemauser.ChangeSchemaRevision(wm.SchemaRevision, *schemaRevision)) + if wm.SchemaRevision != schemaRevision { + changes = append(changes, schemauser.ChangeSchemaRevision(schemaRevision)) } - if !bytes.Equal(wm.Data, data) { + if data != nil && !bytes.Equal(wm.Data, data) { changes = append(changes, schemauser.ChangeData(data)) } if len(changes) == 0 { diff --git a/internal/command/user_v3_test.go b/internal/command/user_v3_test.go index 5bbb9e0c55..69794b3c2e 100644 --- a/internal/command/user_v3_test.go +++ b/internal/command/user_v3_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + "github.com/muhlemmer/gu" "github.com/stretchr/testify/assert" "go.uber.org/mock/gomock" @@ -673,7 +674,7 @@ func TestCommands_CreateSchemaUser(t *testing.T) { "name": "user" }`), ), - schemauser.NewPhoneChangedEvent(context.Background(), + schemauser.NewPhoneUpdatedEvent(context.Background(), &schemauser.NewAggregate("id1", "org1").Aggregate, "+41791234567", ), @@ -751,7 +752,7 @@ func TestCommands_CreateSchemaUser(t *testing.T) { "name": "user" }`), ), - schemauser.NewPhoneChangedEvent(context.Background(), + schemauser.NewPhoneUpdatedEvent(context.Background(), &schemauser.NewAggregate("id1", "org1").Aggregate, "+41791234567", ), @@ -834,7 +835,7 @@ func TestCommands_CreateSchemaUser(t *testing.T) { schemauser.NewEmailVerifiedEvent(context.Background(), &schemauser.NewAggregate("id1", "org1").Aggregate, ), - schemauser.NewPhoneChangedEvent(context.Background(), + schemauser.NewPhoneUpdatedEvent(context.Background(), &schemauser.NewAggregate("id1", "org1").Aggregate, "+41791234567", ), @@ -904,6 +905,7 @@ func TestCommandSide_DeleteSchemaUser(t *testing.T) { type ( args struct { ctx context.Context + orgID string userID string } ) @@ -1088,7 +1090,7 @@ func TestCommandSide_DeleteSchemaUser(t *testing.T) { eventstore: tt.fields.eventstore(t), checkPermission: tt.fields.checkPermission, } - got, err := r.DeleteSchemaUser(tt.args.ctx, tt.args.userID) + got, err := r.DeleteSchemaUser(tt.args.ctx, tt.args.orgID, tt.args.userID) if tt.res.err == nil { assert.NoError(t, err) } @@ -1101,3 +1103,2022 @@ func TestCommandSide_DeleteSchemaUser(t *testing.T) { }) } } + +func TestCommandSide_LockSchemaUser(t *testing.T) { + type fields struct { + eventstore func(*testing.T) *eventstore.Eventstore + checkPermission domain.PermissionCheck + } + type ( + args struct { + ctx context.Context + orgID string + userID string + } + ) + type res struct { + want *domain.ObjectDetails + err func(error) bool + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + name: "userid missing, invalid argument error", + fields: fields{ + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + userID: "", + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "COMMAND-Eu8I2VAfjF", "Errors.IDMissing")) + }, + }, + }, + { + name: "user not existing, not found error", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowNotFound(nil, "COMMAND-G4LOrnjY7q", "Errors.User.NotFound")) + }, + }, + }, + { + name: "user removed, not found error", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "schema", + 1, + json.RawMessage(`{ + "name": "user" + }`), + ), + ), + eventFromEventPusher( + schemauser.NewDeletedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowNotFound(nil, "COMMAND-G4LOrnjY7q", "Errors.User.NotFound")) + }, + }, + }, + { + name: "user locked, precondition error", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "schema", + 1, + json.RawMessage(`{ + "name": "user" + }`), + ), + ), + eventFromEventPusher( + schemauser.NewLockedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowNotFound(nil, "COMMAND-G4LOrnjY7q", "Errors.User.NotFound")) + }, + }, + }, + { + name: "lock user, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "schema", + 1, + json.RawMessage(`{ + "name": "user" + }`), + ), + ), + ), + expectPush( + schemauser.NewLockedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + name: "lock user, no permission", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "schema", + 1, + json.RawMessage(`{ + "name": "user" + }`), + ), + ), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied")) + }, + }, + }, + } + 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.LockSchemaUser(tt.args.ctx, tt.args.orgID, tt.args.userID) + 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 { + assertObjectDetails(t, tt.res.want, got) + } + }) + } +} + +func TestCommandSide_UnlockSchemaUser(t *testing.T) { + type fields struct { + eventstore func(*testing.T) *eventstore.Eventstore + checkPermission domain.PermissionCheck + } + type ( + args struct { + ctx context.Context + orgID string + userID string + } + ) + type res struct { + want *domain.ObjectDetails + err func(error) bool + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + name: "userid missing, invalid argument error", + fields: fields{ + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + userID: "", + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "COMMAND-krXtYscQZh", "Errors.IDMissing")) + }, + }, + }, + { + name: "user not existing, not found error", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowNotFound(nil, "COMMAND-gpBv46Lh9m", "Errors.User.NotFound")) + }, + }, + }, + { + name: "user removed, not found error", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "schema", + 1, + json.RawMessage(`{ + "name": "user" + }`), + ), + ), + eventFromEventPusher( + schemauser.NewDeletedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowNotFound(nil, "COMMAND-gpBv46Lh9m", "Errors.User.NotFound")) + }, + }, + }, + { + name: "user not locked, precondition error", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "schema", + 1, + json.RawMessage(`{ + "name": "user" + }`), + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowNotFound(nil, "COMMAND-gpBv46Lh9m", "Errors.User.NotFound")) + }, + }, + }, + { + name: "unlock user, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "schema", + 1, + json.RawMessage(`{ + "name": "user" + }`), + ), + ), + eventFromEventPusher( + schemauser.NewLockedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + expectPush( + schemauser.NewUnlockedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + name: "unlock user, no permission", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "schema", + 1, + json.RawMessage(`{ + "name": "user" + }`), + ), + ), + eventFromEventPusher( + schemauser.NewLockedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied")) + }, + }, + }, + } + 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.UnlockSchemaUser(tt.args.ctx, tt.args.orgID, tt.args.userID) + 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 { + assertObjectDetails(t, tt.res.want, got) + } + }) + } +} + +func TestCommandSide_DeactivateSchemaUser(t *testing.T) { + type fields struct { + eventstore func(*testing.T) *eventstore.Eventstore + checkPermission domain.PermissionCheck + } + type ( + args struct { + ctx context.Context + orgID string + userID string + } + ) + type res struct { + want *domain.ObjectDetails + err func(error) bool + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + name: "userid missing, invalid argument error", + fields: fields{ + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + userID: "", + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "COMMAND-pjJhge86ZV", "Errors.IDMissing")) + }, + }, + }, + { + name: "user not existing, not found error", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowNotFound(nil, "COMMAND-Ob6lR5iFTe", "Errors.User.NotFound")) + }, + }, + }, + { + name: "user removed, not found error", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "schema", + 1, + json.RawMessage(`{ + "name": "user" + }`), + ), + ), + eventFromEventPusher( + schemauser.NewDeletedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowNotFound(nil, "COMMAND-Ob6lR5iFTe", "Errors.User.NotFound")) + }, + }, + }, + { + name: "user not active, precondition error", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "schema", + 1, + json.RawMessage(`{ + "name": "user" + }`), + ), + ), + eventFromEventPusher( + schemauser.NewDeactivatedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowNotFound(nil, "COMMAND-Ob6lR5iFTe", "Errors.User.NotFound")) + }, + }, + }, + { + name: "deactivate user, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "schema", + 1, + json.RawMessage(`{ + "name": "user" + }`), + ), + ), + ), + expectPush( + schemauser.NewDeactivatedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + name: "deactivate user, no permission", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "schema", + 1, + json.RawMessage(`{ + "name": "user" + }`), + ), + ), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied")) + }, + }, + }, + } + 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.DeactivateSchemaUser(tt.args.ctx, tt.args.orgID, tt.args.userID) + 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 { + assertObjectDetails(t, tt.res.want, got) + } + }) + } +} + +func TestCommandSide_ReactivateSchemaUser(t *testing.T) { + type fields struct { + eventstore func(*testing.T) *eventstore.Eventstore + checkPermission domain.PermissionCheck + } + type ( + args struct { + ctx context.Context + orgID string + userID string + } + ) + type res struct { + want *domain.ObjectDetails + err func(error) bool + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + name: "userid missing, invalid argument error", + fields: fields{ + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + userID: "", + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "COMMAND-17XupGvxBJ", "Errors.IDMissing")) + }, + }, + }, + { + name: "user not existing, not found error", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowNotFound(nil, "COMMAND-rQjbBr4J3j", "Errors.User.NotFound")) + }, + }, + }, + { + name: "user removed, not found error", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "schema", + 1, + json.RawMessage(`{ + "name": "user" + }`), + ), + ), + eventFromEventPusher( + schemauser.NewDeletedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowNotFound(nil, "COMMAND-rQjbBr4J3j", "Errors.User.NotFound")) + }, + }, + }, + { + name: "user not inactive, precondition error", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "schema", + 1, + json.RawMessage(`{ + "name": "user" + }`), + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowNotFound(nil, "COMMAND-rQjbBr4J3j", "Errors.User.NotFound")) + }, + }, + }, + { + name: "activate user, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "schema", + 1, + json.RawMessage(`{ + "name": "user" + }`), + ), + ), + eventFromEventPusher( + schemauser.NewDeactivatedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + expectPush( + schemauser.NewActivatedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + name: "activate user, no permission", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "schema", + 1, + json.RawMessage(`{ + "name": "user" + }`), + ), + ), + eventFromEventPusher( + schemauser.NewDeactivatedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied")) + }, + }, + }, + } + 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.ActivateSchemaUser(tt.args.ctx, tt.args.orgID, tt.args.userID) + 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 { + assertObjectDetails(t, tt.res.want, got) + } + }) + } +} + +func TestCommands_ChangeSchemaUser(t *testing.T) { + type fields struct { + eventstore func(t *testing.T) *eventstore.Eventstore + checkPermission domain.PermissionCheck + newCode encrypedCodeFunc + } + type args struct { + ctx context.Context + user *ChangeSchemaUser + } + type res struct { + returnCodeEmail string + returnCodePhone string + details *domain.ObjectDetails + err func(error) bool + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + "no userID, error", + fields{ + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUser{}, + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "COMMAND-gEJR1QOGHb", "Errors.IDMissing")) + }, + }, + }, + { + "schema not existing, error", + fields{ + eventstore: expectEventstore( + expectFilter(), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUser{ + ID: "user1", + SchemaID: gu.Ptr("type"), + }, + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowPreconditionFailed(nil, "COMMAND-VLDTtxT3If", "Errors.UserSchema.NotExists")) + }, + }, + }, + { + "no valid email, error", + fields{ + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUser{ + ID: "user1", + Email: &Email{Address: "noemail"}, + }, + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "EMAIL-599BI", "Errors.User.Email.Invalid")) + }, + }, + }, + { + "no valid phone, error", + fields{ + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUser{ + ID: "user1", + Phone: &Phone{Number: "invalid"}, + }, + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "PHONE-so0wa", "Errors.User.Phone.Invalid")) + }, + }, + }, + { + "user update, no permission", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUser{ + ID: "user1", + Data: json.RawMessage(`{ + "name": "user" + }`), + }, + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied")) + }, + }, + }, + { + "user updated, same schema", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + ), + expectFilter( + eventFromEventPusher( + schema.NewCreatedEvent( + context.Background(), + &schema.NewAggregate("id1", "instanceID").Aggregate, + "type", + json.RawMessage(`{ + "$schema": "urn:zitadel:schema:v1", + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + }`), + []domain.AuthenticatorType{domain.AuthenticatorTypeUsername}, + ), + ), + ), + expectPush( + schemauser.NewUpdatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + []schemauser.Changes{ + schemauser.ChangeData( + json.RawMessage(`{ + "name": "user2" + }`), + ), + }, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUser{ + ID: "user1", + Data: json.RawMessage(`{ + "name": "user2" + }`), + }, + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + "user updated, changed schema", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schema.NewCreatedEvent( + context.Background(), + &schema.NewAggregate("id2", "instanceID").Aggregate, + "type", + json.RawMessage(`{ + "$schema": "urn:zitadel:schema:v1", + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + }`), + []domain.AuthenticatorType{domain.AuthenticatorTypeUsername}, + ), + ), + ), + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + ), + expectPush( + schemauser.NewUpdatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + []schemauser.Changes{ + schemauser.ChangeSchemaID("id2"), + }, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUser{ + ID: "user1", + SchemaID: gu.Ptr("id2"), + }, + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + "user updated, new schema", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schema.NewCreatedEvent( + context.Background(), + &schema.NewAggregate("id2", "instanceID").Aggregate, + "type", + json.RawMessage(`{ + "$schema": "urn:zitadel:schema:v1", + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + }`), + []domain.AuthenticatorType{domain.AuthenticatorTypeUsername}, + ), + ), + ), + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + ), + expectPush( + schemauser.NewUpdatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + []schemauser.Changes{ + schemauser.ChangeSchemaID("id2"), + schemauser.ChangeData( + json.RawMessage(`{ + "name": "user2" + }`), + ), + }, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUser{ + ID: "user1", + SchemaID: gu.Ptr("id2"), + Data: json.RawMessage(`{ + "name": "user2" + }`), + }, + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + "user updated, same schema revision", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name1": "user1" + }`), + ), + ), + ), + expectFilter( + eventFromEventPusher( + schema.NewCreatedEvent( + context.Background(), + &schema.NewAggregate("id1", "instanceID").Aggregate, + "type", + json.RawMessage(`{ + "$schema": "urn:zitadel:schema:v1", + "type": "object", + "properties": { + "name1": { + "type": "string" + } + } + }`), + []domain.AuthenticatorType{domain.AuthenticatorTypeUsername}, + ), + ), + eventFromEventPusher( + schema.NewUpdatedEvent( + context.Background(), + &schema.NewAggregate("id1", "instanceID").Aggregate, + []schema.Changes{ + schema.IncreaseRevision(1), + schema.ChangeSchema(json.RawMessage(`{ + "$schema": "urn:zitadel:schema:v1", + "type": "object", + "properties": { + "name2": { + "type": "string" + } + } + }`)), + }, + ), + ), + ), + expectPush( + schemauser.NewUpdatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + []schemauser.Changes{ + schemauser.ChangeSchemaRevision(2), + schemauser.ChangeData( + json.RawMessage(`{ + "name2": "user2" + }`), + ), + }, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUser{ + ID: "user1", + Data: json.RawMessage(`{ + "name2": "user2" + }`), + }, + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + "user updated, new schema and revision", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schema.NewCreatedEvent( + context.Background(), + &schema.NewAggregate("id2", "instanceID").Aggregate, + "type", + json.RawMessage(`{ + "$schema": "urn:zitadel:schema:v1", + "type": "object", + "properties": { + "name2": { + "type": "string" + } + } + }`), + []domain.AuthenticatorType{domain.AuthenticatorTypeUsername}, + ), + ), + ), + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 2, + json.RawMessage(`{ + "name1": "user1" + }`), + ), + ), + ), + expectPush( + schemauser.NewUpdatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + []schemauser.Changes{ + schemauser.ChangeSchemaID("id2"), + schemauser.ChangeSchemaRevision(1), + schemauser.ChangeData( + json.RawMessage(`{ + "name2": "user2" + }`), + ), + }, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUser{ + ID: "user1", + SchemaID: gu.Ptr("id2"), + Data: json.RawMessage(`{ + "name2": "user2" + }`), + }, + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + "user update, no field permission as admin", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schema.NewCreatedEvent( + context.Background(), + &schema.NewAggregate("id1", "instanceID").Aggregate, + "type", + json.RawMessage(`{ + "$schema": "urn:zitadel:schema:v1", + "type": "object", + "properties": { + "name": { + "urn:zitadel:schema:permission": { + "owner": "r" + }, + "type": "string" + } + } + }`), + []domain.AuthenticatorType{domain.AuthenticatorTypeUsername}, + ), + ), + ), + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUser{ + ID: "user1", + SchemaID: gu.Ptr("id1"), + Data: json.RawMessage(`{ + "name": "user" + }`), + }, + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowPreconditionFailed(nil, "COMMAND-SlKXqLSeL6", "Errors.UserSchema.Data.Invalid")) + }, + }, + }, + { + "user update, no field permission as user", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schema.NewCreatedEvent( + context.Background(), + &schema.NewAggregate("id1", "instanceID").Aggregate, + "type", + json.RawMessage(`{ + "$schema": "urn:zitadel:schema:v1", + "type": "object", + "properties": { + "name": { + "urn:zitadel:schema:permission": { + "self": "r" + }, + "type": "string" + } + } + }`), + []domain.AuthenticatorType{domain.AuthenticatorTypeUsername}, + ), + ), + ), + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + ), + ), + }, + args{ + ctx: authz.NewMockContext("instanceID", "org1", "user1"), + user: &ChangeSchemaUser{ + ID: "user1", + SchemaID: gu.Ptr("type"), + Data: json.RawMessage(`{ + "name": "user" + }`), + }, + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowPreconditionFailed(nil, "COMMAND-SlKXqLSeL6", "Errors.UserSchema.Data.Invalid")) + }, + }, + }, + { + "user update, invalid data type", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schema.NewCreatedEvent( + context.Background(), + &schema.NewAggregate("id1", "instanceID").Aggregate, + "type", + json.RawMessage(`{ + "$schema": "urn:zitadel:schema:v1", + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + }`), + []domain.AuthenticatorType{domain.AuthenticatorTypeUsername}, + ), + ), + ), + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "org1", "user1"), + user: &ChangeSchemaUser{ + ID: "user1", + SchemaID: gu.Ptr("type"), + Data: json.RawMessage(`{ + "name": 1 + }`), + }, + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowPreconditionFailed(nil, "COMMAND-SlKXqLSeL6", "Errors.UserSchema.Data.Invalid")) + }, + }, + }, + { + "user update, additional property", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schema.NewCreatedEvent( + context.Background(), + &schema.NewAggregate("id1", "instanceID").Aggregate, + "type", + json.RawMessage(`{ + "$schema": "urn:zitadel:schema:v1", + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + }`), + []domain.AuthenticatorType{domain.AuthenticatorTypeUsername}, + ), + ), + ), + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + ), + expectPush( + schemauser.NewUpdatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + []schemauser.Changes{ + schemauser.ChangeData( + json.RawMessage(`{ + "name": "user1", + "additional": "property" + }`), + ), + }, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUser{ + ID: "user1", + SchemaID: gu.Ptr("id1"), + Data: json.RawMessage(`{ + "name": "user1", + "additional": "property" + }`), + }, + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + "user update, invalid data attribute name", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schema.NewCreatedEvent( + context.Background(), + &schema.NewAggregate("id1", "instanceID").Aggregate, + "type", + json.RawMessage(`{ + "$schema": "urn:zitadel:schema:v1", + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "additionalProperties": false + }`), + []domain.AuthenticatorType{domain.AuthenticatorTypeUsername}, + ), + ), + ), + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "org1", "user1"), + user: &ChangeSchemaUser{ + ID: "user1", + SchemaID: gu.Ptr("type"), + Data: json.RawMessage(`{ + "invalid": "user" + }`), + }, + }, + res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowPreconditionFailed(nil, "COMMAND-SlKXqLSeL6", "Errors.UserSchema.Data.Invalid")) + }, + }, + }, + { + "user update, email not changed", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + eventFromEventPusher( + schemauser.NewEmailUpdatedEvent( + context.Background(), + &schema.NewAggregate("id1", "instanceID").Aggregate, + "test@example.com", + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUser{ + ID: "user1", + Email: &Email{ + Address: "test@example.com", + ReturnCode: true, + URLTemplate: "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}", + }, + }, + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + "user update, email return", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schema.NewCreatedEvent( + context.Background(), + &schema.NewAggregate("id1", "instanceID").Aggregate, + "type", + json.RawMessage(`{ + "$schema": "urn:zitadel:schema:v1", + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + }`), + []domain.AuthenticatorType{domain.AuthenticatorTypeUsername}, + ), + ), + ), + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "id1", + 1, + json.RawMessage(`{ + "name": "user1" + }`), + ), + ), + ), + expectPush( + schemauser.NewEmailUpdatedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "test@example.com", + ), + schemauser.NewEmailCodeAddedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("emailverify"), + }, + time.Hour*1, + "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}", + true, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + newCode: mockEncryptedCode("emailverify", time.Hour), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUser{ + ID: "user1", + SchemaID: gu.Ptr("id1"), + Email: &Email{ + Address: "test@example.com", + ReturnCode: true, + URLTemplate: "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}", + }, + }, + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + returnCodeEmail: "emailverify", + }, + }, + { + "user updated, email to verify", + fields{ + eventstore: expectEventstore( + expectFilter( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "type", + 1, + json.RawMessage(`{ + "name": "user" + }`), + )), + expectPush( + schemauser.NewEmailUpdatedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "test@example.com", + ), + schemauser.NewEmailCodeAddedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("emailverify"), + }, + time.Hour*1, + "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}", + false, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + newCode: mockEncryptedCode("emailverify", time.Hour), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUser{ + ID: "user1", + Email: &Email{ + Address: "test@example.com", + URLTemplate: "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}", + }, + }, + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + "user updated, phone no change", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "type", + 1, + json.RawMessage(`{ + "name": "user" + }`), + ), + ), + eventFromEventPusher( + schemauser.NewPhoneUpdatedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "+41791234567", + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUser{ + ID: "user1", + Phone: &Phone{ + Number: "+41791234567", + ReturnCode: true, + }, + }, + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + "user updated, phone return", + fields{ + eventstore: expectEventstore( + expectFilter( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "type", + 1, + json.RawMessage(`{ + "name": "user" + }`), + ), + ), + expectPush( + schemauser.NewPhoneUpdatedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "+41791234567", + ), + schemauser.NewPhoneCodeAddedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("phoneverify"), + }, + time.Hour*1, + true, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + newCode: mockEncryptedCode("phoneverify", time.Hour), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUser{ + ID: "user1", + Phone: &Phone{ + Number: "+41791234567", + ReturnCode: true, + }, + }, + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + returnCodePhone: "phoneverify", + }, + }, + { + "user updated, phone to verify", + fields{ + eventstore: expectEventstore( + expectFilter( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("id1", "org1").Aggregate, + "type", + 1, + json.RawMessage(`{ + "name": "user" + }`), + ), + ), + expectPush( + schemauser.NewPhoneUpdatedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "+41791234567", + ), + schemauser.NewPhoneCodeAddedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("phoneverify"), + }, + time.Hour*1, + false, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + newCode: mockEncryptedCode("phoneverify", time.Hour), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUser{ + ID: "user1", + Phone: &Phone{ + Number: "+41791234567", + }, + }, + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + "user updated, full verified", + fields{ + eventstore: expectEventstore( + expectFilter( + schemauser.NewCreatedEvent( + context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "type", + 1, + json.RawMessage(`{ + "name": "user" + }`), + ), + ), + expectPush( + schemauser.NewEmailUpdatedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "test@example.com", + ), + schemauser.NewEmailVerifiedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + ), + schemauser.NewPhoneUpdatedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + "+41791234567", + ), + schemauser.NewPhoneVerifiedEvent(context.Background(), + &schemauser.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "", ""), + user: &ChangeSchemaUser{ + ID: "user1", + Email: &Email{Address: "test@example.com", Verified: true}, + Phone: &Phone{Number: "+41791234567", Verified: true}, + }, + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore(t), + checkPermission: tt.fields.checkPermission, + newEncryptedCode: tt.fields.newCode, + } + err := c.ChangeSchemaUser(tt.args.ctx, tt.args.user, crypto.CreateMockEncryptionAlg(gomock.NewController(t))) + 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 { + assertObjectDetails(t, tt.res.details, tt.args.user.Details) + } + + if tt.res.returnCodePhone != "" { + assert.Equal(t, tt.res.returnCodePhone, tt.args.user.ReturnCodePhone) + } + if tt.res.returnCodeEmail != "" { + assert.Equal(t, tt.res.returnCodeEmail, tt.args.user.ReturnCodeEmail) + } + }) + } +} diff --git a/internal/integration/client.go b/internal/integration/client.go index 82b8ab3b6e..9bf855f5ce 100644 --- a/internal/integration/client.go +++ b/internal/integration/client.go @@ -28,7 +28,7 @@ import ( object_v3alpha "github.com/zitadel/zitadel/pkg/grpc/object/v3alpha" oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2" oidc_pb_v2beta "github.com/zitadel/zitadel/pkg/grpc/oidc/v2beta" - org "github.com/zitadel/zitadel/pkg/grpc/org/v2" + "github.com/zitadel/zitadel/pkg/grpc/org/v2" org_v2beta "github.com/zitadel/zitadel/pkg/grpc/org/v2beta" action "github.com/zitadel/zitadel/pkg/grpc/resources/action/v3alpha" user_v3alpha "github.com/zitadel/zitadel/pkg/grpc/resources/user/v3alpha" @@ -784,3 +784,55 @@ func (i *Instance) CreateInviteCode(ctx context.Context, userID string) *user_v2 logging.OnError(err).Fatal("create invite code") return user } + +func (i *Instance) LockSchemaUser(ctx context.Context, orgID string, userID string) *user_v3alpha.LockUserResponse { + var org *object_v3alpha.Organization + if orgID != "" { + org = &object_v3alpha.Organization{Property: &object_v3alpha.Organization_OrgId{OrgId: orgID}} + } + user, err := i.Client.UserV3Alpha.LockUser(ctx, &user_v3alpha.LockUserRequest{ + Organization: org, + Id: userID, + }) + logging.OnError(err).Fatal("lock user") + return user +} + +func (i *Instance) UnlockSchemaUser(ctx context.Context, orgID string, userID string) *user_v3alpha.UnlockUserResponse { + var org *object_v3alpha.Organization + if orgID != "" { + org = &object_v3alpha.Organization{Property: &object_v3alpha.Organization_OrgId{OrgId: orgID}} + } + user, err := i.Client.UserV3Alpha.UnlockUser(ctx, &user_v3alpha.UnlockUserRequest{ + Organization: org, + Id: userID, + }) + logging.OnError(err).Fatal("unlock user") + return user +} + +func (i *Instance) DeactivateSchemaUser(ctx context.Context, orgID string, userID string) *user_v3alpha.DeactivateUserResponse { + var org *object_v3alpha.Organization + if orgID != "" { + org = &object_v3alpha.Organization{Property: &object_v3alpha.Organization_OrgId{OrgId: orgID}} + } + user, err := i.Client.UserV3Alpha.DeactivateUser(ctx, &user_v3alpha.DeactivateUserRequest{ + Organization: org, + Id: userID, + }) + logging.OnError(err).Fatal("deactivate user") + return user +} + +func (i *Instance) ActivateSchemaUser(ctx context.Context, orgID string, userID string) *user_v3alpha.ActivateUserResponse { + var org *object_v3alpha.Organization + if orgID != "" { + org = &object_v3alpha.Organization{Property: &object_v3alpha.Organization_OrgId{OrgId: orgID}} + } + user, err := i.Client.UserV3Alpha.ActivateUser(ctx, &user_v3alpha.ActivateUserRequest{ + Organization: org, + Id: userID, + }) + logging.OnError(err).Fatal("reactivate user") + return user +} diff --git a/internal/repository/user/schemauser/aggregate.go b/internal/repository/user/schemauser/aggregate.go index 1c9901c08c..18b901281e 100644 --- a/internal/repository/user/schemauser/aggregate.go +++ b/internal/repository/user/schemauser/aggregate.go @@ -5,8 +5,8 @@ import ( ) const ( - AggregateType = "user" - AggregateVersion = "v3" + AggregateType = "schemauser" + AggregateVersion = "v1" ) type Aggregate struct { diff --git a/internal/repository/user/schemauser/email.go b/internal/repository/user/schemauser/email.go index 07ae1bdf71..b5e4206252 100644 --- a/internal/repository/user/schemauser/email.go +++ b/internal/repository/user/schemauser/email.go @@ -8,7 +8,6 @@ import ( "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" - "github.com/zitadel/zitadel/internal/zerrors" ) const ( @@ -21,11 +20,15 @@ const ( ) type EmailUpdatedEvent struct { - eventstore.BaseEvent `json:"-"` + *eventstore.BaseEvent `json:"-"` EmailAddress domain.EmailAddress `json:"email,omitempty"` } +func (e *EmailUpdatedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = event +} + func (e *EmailUpdatedEvent) Payload() interface{} { return e } @@ -36,7 +39,7 @@ func (e *EmailUpdatedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { func NewEmailUpdatedEvent(ctx context.Context, aggregate *eventstore.Aggregate, emailAddress domain.EmailAddress) *EmailUpdatedEvent { return &EmailUpdatedEvent{ - BaseEvent: *eventstore.NewBaseEventForPush( + BaseEvent: eventstore.NewBaseEventForPush( ctx, aggregate, EmailUpdatedType, @@ -45,24 +48,16 @@ func NewEmailUpdatedEvent(ctx context.Context, aggregate *eventstore.Aggregate, } } -func EmailUpdatedEventMapper(event eventstore.Event) (eventstore.Event, error) { - emailChangedEvent := &EmailUpdatedEvent{ - BaseEvent: *eventstore.BaseEventFromRepo(event), - } - err := event.Unmarshal(emailChangedEvent) - if err != nil { - return nil, zerrors.ThrowInternal(err, "USER-4M0sd", "unable to unmarshal human password changed") - } - - return emailChangedEvent, nil -} - type EmailVerifiedEvent struct { - eventstore.BaseEvent `json:"-"` + *eventstore.BaseEvent `json:"-"` IsEmailVerified bool `json:"-"` } +func (e *EmailVerifiedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = event +} + func (e *EmailVerifiedEvent) Payload() interface{} { return nil } @@ -73,7 +68,7 @@ func (e *EmailVerifiedEvent) UniqueConstraints() []*eventstore.UniqueConstraint func NewEmailVerifiedEvent(ctx context.Context, aggregate *eventstore.Aggregate) *EmailVerifiedEvent { return &EmailVerifiedEvent{ - BaseEvent: *eventstore.NewBaseEventForPush( + BaseEvent: eventstore.NewBaseEventForPush( ctx, aggregate, EmailVerifiedType, @@ -81,18 +76,13 @@ func NewEmailVerifiedEvent(ctx context.Context, aggregate *eventstore.Aggregate) } } -func HumanVerifiedEventMapper(event eventstore.Event) (eventstore.Event, error) { - emailVerified := &EmailVerifiedEvent{ - BaseEvent: *eventstore.BaseEventFromRepo(event), - IsEmailVerified: true, - } - return emailVerified, nil -} - type EmailVerificationFailedEvent struct { - eventstore.BaseEvent `json:"-"` + *eventstore.BaseEvent `json:"-"` } +func (e *EmailVerificationFailedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = event +} func (e *EmailVerificationFailedEvent) Payload() interface{} { return nil } @@ -101,9 +91,9 @@ func (e *EmailVerificationFailedEvent) UniqueConstraints() []*eventstore.UniqueC return nil } -func NewHumanEmailVerificationFailedEvent(ctx context.Context, aggregate *eventstore.Aggregate) *EmailVerificationFailedEvent { +func NewEmailVerificationFailedEvent(ctx context.Context, aggregate *eventstore.Aggregate) *EmailVerificationFailedEvent { return &EmailVerificationFailedEvent{ - BaseEvent: *eventstore.NewBaseEventForPush( + BaseEvent: eventstore.NewBaseEventForPush( ctx, aggregate, EmailVerificationFailedType, @@ -111,14 +101,8 @@ func NewHumanEmailVerificationFailedEvent(ctx context.Context, aggregate *events } } -func EmailVerificationFailedEventMapper(event eventstore.Event) (eventstore.Event, error) { - return &EmailVerificationFailedEvent{ - BaseEvent: *eventstore.BaseEventFromRepo(event), - }, nil -} - type EmailCodeAddedEvent struct { - eventstore.BaseEvent `json:"-"` + *eventstore.BaseEvent `json:"-"` Code *crypto.CryptoValue `json:"code,omitempty"` Expiry time.Duration `json:"expiry,omitempty"` @@ -127,6 +111,10 @@ type EmailCodeAddedEvent struct { TriggeredAtOrigin string `json:"triggerOrigin,omitempty"` } +func (e *EmailCodeAddedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = event +} + func (e *EmailCodeAddedEvent) Payload() interface{} { return e } @@ -148,7 +136,7 @@ func NewEmailCodeAddedEvent( codeReturned bool, ) *EmailCodeAddedEvent { return &EmailCodeAddedEvent{ - BaseEvent: *eventstore.NewBaseEventForPush( + BaseEvent: eventstore.NewBaseEventForPush( ctx, aggregate, EmailCodeAddedType, @@ -161,22 +149,13 @@ func NewEmailCodeAddedEvent( } } -func EmailCodeAddedEventMapper(event eventstore.Event) (eventstore.Event, error) { - codeAdded := &EmailCodeAddedEvent{ - BaseEvent: *eventstore.BaseEventFromRepo(event), - } - err := event.Unmarshal(codeAdded) - if err != nil { - return nil, zerrors.ThrowInternal(err, "USER-3M0sd", "unable to unmarshal human email code added") - } - - return codeAdded, nil -} - type EmailCodeSentEvent struct { - eventstore.BaseEvent `json:"-"` + *eventstore.BaseEvent `json:"-"` } +func (e *EmailCodeSentEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = event +} func (e *EmailCodeSentEvent) Payload() interface{} { return nil } @@ -185,18 +164,12 @@ func (e *EmailCodeSentEvent) UniqueConstraints() []*eventstore.UniqueConstraint return nil } -func NewHumanEmailCodeSentEvent(ctx context.Context, aggregate *eventstore.Aggregate) *EmailCodeSentEvent { +func NewEmailCodeSentEvent(ctx context.Context, aggregate *eventstore.Aggregate) *EmailCodeSentEvent { return &EmailCodeSentEvent{ - BaseEvent: *eventstore.NewBaseEventForPush( + BaseEvent: eventstore.NewBaseEventForPush( ctx, aggregate, EmailCodeSentType, ), } } - -func EmailCodeSentEventMapper(event eventstore.Event) (eventstore.Event, error) { - return &EmailCodeSentEvent{ - BaseEvent: *eventstore.BaseEventFromRepo(event), - }, nil -} diff --git a/internal/repository/user/schemauser/eventstore.go b/internal/repository/user/schemauser/eventstore.go index b9cf03e5d3..85ad15c17d 100644 --- a/internal/repository/user/schemauser/eventstore.go +++ b/internal/repository/user/schemauser/eventstore.go @@ -6,4 +6,18 @@ func init() { eventstore.RegisterFilterEventMapper(AggregateType, CreatedType, eventstore.GenericEventMapper[CreatedEvent]) eventstore.RegisterFilterEventMapper(AggregateType, UpdatedType, eventstore.GenericEventMapper[UpdatedEvent]) eventstore.RegisterFilterEventMapper(AggregateType, DeletedType, eventstore.GenericEventMapper[DeletedEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, LockedType, eventstore.GenericEventMapper[LockedEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, UnlockedType, eventstore.GenericEventMapper[UnlockedEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, ActivatedType, eventstore.GenericEventMapper[ActivatedEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, DeactivatedType, eventstore.GenericEventMapper[DeactivatedEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, EmailUpdatedType, eventstore.GenericEventMapper[EmailUpdatedEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, EmailCodeAddedType, eventstore.GenericEventMapper[EmailCodeAddedEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, EmailCodeSentType, eventstore.GenericEventMapper[EmailCodeSentEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, EmailVerifiedType, eventstore.GenericEventMapper[EmailVerifiedEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, EmailVerificationFailedType, eventstore.GenericEventMapper[EmailVerificationFailedEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, PhoneUpdatedType, eventstore.GenericEventMapper[PhoneUpdatedEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, PhoneCodeAddedType, eventstore.GenericEventMapper[PhoneCodeAddedEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, PhoneCodeSentType, eventstore.GenericEventMapper[PhoneCodeSentEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, PhoneVerifiedType, eventstore.GenericEventMapper[PhoneVerifiedEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, PhoneVerificationFailedType, eventstore.GenericEventMapper[PhoneVerificationFailedEvent]) } diff --git a/internal/repository/user/schemauser/phone.go b/internal/repository/user/schemauser/phone.go index 5110772c04..a491dab776 100644 --- a/internal/repository/user/schemauser/phone.go +++ b/internal/repository/user/schemauser/phone.go @@ -8,7 +8,6 @@ import ( "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" - "github.com/zitadel/zitadel/internal/zerrors" ) const ( @@ -20,23 +19,27 @@ const ( PhoneCodeSentType = phoneEventPrefix + "code.sent" ) -type PhoneChangedEvent struct { - eventstore.BaseEvent `json:"-"` +type PhoneUpdatedEvent struct { + *eventstore.BaseEvent `json:"-"` PhoneNumber domain.PhoneNumber `json:"phone,omitempty"` } -func (e *PhoneChangedEvent) Payload() interface{} { +func (e *PhoneUpdatedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = event +} + +func (e *PhoneUpdatedEvent) Payload() interface{} { return e } -func (e *PhoneChangedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { +func (e *PhoneUpdatedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { return nil } -func NewPhoneChangedEvent(ctx context.Context, aggregate *eventstore.Aggregate, phone domain.PhoneNumber) *PhoneChangedEvent { - return &PhoneChangedEvent{ - BaseEvent: *eventstore.NewBaseEventForPush( +func NewPhoneUpdatedEvent(ctx context.Context, aggregate *eventstore.Aggregate, phone domain.PhoneNumber) *PhoneUpdatedEvent { + return &PhoneUpdatedEvent{ + BaseEvent: eventstore.NewBaseEventForPush( ctx, aggregate, PhoneUpdatedType, @@ -45,24 +48,15 @@ func NewPhoneChangedEvent(ctx context.Context, aggregate *eventstore.Aggregate, } } -func PhoneChangedEventMapper(event eventstore.Event) (eventstore.Event, error) { - phoneChangedEvent := &PhoneChangedEvent{ - BaseEvent: *eventstore.BaseEventFromRepo(event), - } - err := event.Unmarshal(phoneChangedEvent) - if err != nil { - return nil, zerrors.ThrowInternal(err, "USER-5M0pd", "unable to unmarshal phone changed") - } - - return phoneChangedEvent, nil -} - type PhoneVerifiedEvent struct { - eventstore.BaseEvent `json:"-"` + *eventstore.BaseEvent `json:"-"` IsPhoneVerified bool `json:"-"` } +func (e *PhoneVerifiedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = event +} func (e *PhoneVerifiedEvent) Payload() interface{} { return nil } @@ -73,7 +67,7 @@ func (e *PhoneVerifiedEvent) UniqueConstraints() []*eventstore.UniqueConstraint func NewPhoneVerifiedEvent(ctx context.Context, aggregate *eventstore.Aggregate) *PhoneVerifiedEvent { return &PhoneVerifiedEvent{ - BaseEvent: *eventstore.NewBaseEventForPush( + BaseEvent: eventstore.NewBaseEventForPush( ctx, aggregate, PhoneVerifiedType, @@ -81,15 +75,12 @@ func NewPhoneVerifiedEvent(ctx context.Context, aggregate *eventstore.Aggregate) } } -func PhoneVerifiedEventMapper(event eventstore.Event) (eventstore.Event, error) { - return &PhoneVerifiedEvent{ - BaseEvent: *eventstore.BaseEventFromRepo(event), - IsPhoneVerified: true, - }, nil +type PhoneVerificationFailedEvent struct { + *eventstore.BaseEvent `json:"-"` } -type PhoneVerificationFailedEvent struct { - eventstore.BaseEvent `json:"-"` +func (e *PhoneVerificationFailedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = event } func (e *PhoneVerificationFailedEvent) Payload() interface{} { @@ -102,7 +93,7 @@ func (e *PhoneVerificationFailedEvent) UniqueConstraints() []*eventstore.UniqueC func NewPhoneVerificationFailedEvent(ctx context.Context, aggregate *eventstore.Aggregate) *PhoneVerificationFailedEvent { return &PhoneVerificationFailedEvent{ - BaseEvent: *eventstore.NewBaseEventForPush( + BaseEvent: eventstore.NewBaseEventForPush( ctx, aggregate, PhoneVerificationFailedType, @@ -110,14 +101,8 @@ func NewPhoneVerificationFailedEvent(ctx context.Context, aggregate *eventstore. } } -func PhoneVerificationFailedEventMapper(event eventstore.Event) (eventstore.Event, error) { - return &PhoneVerificationFailedEvent{ - BaseEvent: *eventstore.BaseEventFromRepo(event), - }, nil -} - type PhoneCodeAddedEvent struct { - eventstore.BaseEvent `json:"-"` + *eventstore.BaseEvent `json:"-"` Code *crypto.CryptoValue `json:"code,omitempty"` Expiry time.Duration `json:"expiry,omitempty"` @@ -137,6 +122,10 @@ func (e *PhoneCodeAddedEvent) TriggerOrigin() string { return e.TriggeredAtOrigin } +func (e *PhoneCodeAddedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = event +} + func NewPhoneCodeAddedEvent( ctx context.Context, aggregate *eventstore.Aggregate, @@ -145,7 +134,7 @@ func NewPhoneCodeAddedEvent( codeReturned bool, ) *PhoneCodeAddedEvent { return &PhoneCodeAddedEvent{ - BaseEvent: *eventstore.NewBaseEventForPush( + BaseEvent: eventstore.NewBaseEventForPush( ctx, aggregate, PhoneCodeAddedType, @@ -157,20 +146,8 @@ func NewPhoneCodeAddedEvent( } } -func PhoneCodeAddedEventMapper(event eventstore.Event) (eventstore.Event, error) { - codeAdded := &PhoneCodeAddedEvent{ - BaseEvent: *eventstore.BaseEventFromRepo(event), - } - err := event.Unmarshal(codeAdded) - if err != nil { - return nil, zerrors.ThrowInternal(err, "USER-6Ms9d", "unable to unmarshal phone code added") - } - - return codeAdded, nil -} - type PhoneCodeSentEvent struct { - eventstore.BaseEvent `json:"-"` + *eventstore.BaseEvent `json:"-"` } func (e *PhoneCodeSentEvent) Payload() interface{} { @@ -181,18 +158,16 @@ func (e *PhoneCodeSentEvent) UniqueConstraints() []*eventstore.UniqueConstraint return nil } +func (e *PhoneCodeSentEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = event +} + func NewPhoneCodeSentEvent(ctx context.Context, aggregate *eventstore.Aggregate) *PhoneCodeSentEvent { return &PhoneCodeSentEvent{ - BaseEvent: *eventstore.NewBaseEventForPush( + BaseEvent: eventstore.NewBaseEventForPush( ctx, aggregate, PhoneCodeSentType, ), } } - -func PhoneCodeSentEventMapper(event eventstore.Event) (eventstore.Event, error) { - return &PhoneCodeSentEvent{ - BaseEvent: *eventstore.BaseEventFromRepo(event), - }, nil -} diff --git a/internal/repository/user/schemauser/user.go b/internal/repository/user/schemauser/user.go index 4c88d53087..6583e57366 100644 --- a/internal/repository/user/schemauser/user.go +++ b/internal/repository/user/schemauser/user.go @@ -8,10 +8,14 @@ import ( ) const ( - eventPrefix = "user." - CreatedType = eventPrefix + "created" - UpdatedType = eventPrefix + "updated" - DeletedType = eventPrefix + "deleted" + eventPrefix = "schemauser." + CreatedType = eventPrefix + "created" + UpdatedType = eventPrefix + "updated" + DeletedType = eventPrefix + "deleted" + LockedType = eventPrefix + "locked" + UnlockedType = eventPrefix + "unlocked" + DeactivatedType = eventPrefix + "deactivated" + ActivatedType = eventPrefix + "activated" ) type CreatedEvent struct { @@ -60,8 +64,6 @@ type UpdatedEvent struct { SchemaID *string `json:"schemaID,omitempty"` SchemaRevision *uint64 `json:"schemaRevision,omitempty"` Data json.RawMessage `json:"schema,omitempty"` - oldSchemaID string - oldRevision uint64 } func (e *UpdatedEvent) SetBaseEvent(event *eventstore.BaseEvent) { @@ -95,16 +97,14 @@ func NewUpdatedEvent( type Changes func(event *UpdatedEvent) -func ChangeSchemaID(oldSchemaID, schemaID string) func(event *UpdatedEvent) { +func ChangeSchemaID(schemaID string) func(event *UpdatedEvent) { return func(e *UpdatedEvent) { e.SchemaID = &schemaID - e.oldSchemaID = oldSchemaID } } -func ChangeSchemaRevision(oldSchemaRevision, schemaRevision uint64) func(event *UpdatedEvent) { +func ChangeSchemaRevision(schemaRevision uint64) func(event *UpdatedEvent) { return func(e *UpdatedEvent) { e.SchemaRevision = &schemaRevision - e.oldRevision = oldSchemaRevision } } @@ -142,3 +142,119 @@ func NewDeletedEvent( ), } } + +type LockedEvent struct { + *eventstore.BaseEvent `json:"-"` +} + +func (e *LockedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = event +} + +func (e *LockedEvent) Payload() interface{} { + return e +} + +func (e *LockedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + return nil +} + +func NewLockedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, +) *LockedEvent { + return &LockedEvent{ + BaseEvent: eventstore.NewBaseEventForPush( + ctx, + aggregate, + LockedType, + ), + } +} + +type UnlockedEvent struct { + *eventstore.BaseEvent `json:"-"` +} + +func (e *UnlockedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = event +} + +func (e *UnlockedEvent) Payload() interface{} { + return e +} + +func (e *UnlockedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + return nil +} + +func NewUnlockedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, +) *UnlockedEvent { + return &UnlockedEvent{ + BaseEvent: eventstore.NewBaseEventForPush( + ctx, + aggregate, + UnlockedType, + ), + } +} + +type DeactivatedEvent struct { + *eventstore.BaseEvent `json:"-"` +} + +func (e *DeactivatedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = event +} + +func (e *DeactivatedEvent) Payload() interface{} { + return e +} + +func (e *DeactivatedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + return nil +} + +func NewDeactivatedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, +) *DeactivatedEvent { + return &DeactivatedEvent{ + BaseEvent: eventstore.NewBaseEventForPush( + ctx, + aggregate, + DeactivatedType, + ), + } +} + +type ActivatedEvent struct { + *eventstore.BaseEvent `json:"-"` +} + +func (e *ActivatedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = event +} + +func (e *ActivatedEvent) Payload() interface{} { + return e +} + +func (e *ActivatedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + return nil +} + +func NewActivatedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, +) *ActivatedEvent { + return &ActivatedEvent{ + BaseEvent: eventstore.NewBaseEventForPush( + ctx, + aggregate, + ActivatedType, + ), + } +} diff --git a/proto/zitadel/resources/user/v3alpha/user_service.proto b/proto/zitadel/resources/user/v3alpha/user_service.proto index d39ac1930a..91831bdc40 100644 --- a/proto/zitadel/resources/user/v3alpha/user_service.proto +++ b/proto/zitadel/resources/user/v3alpha/user_service.proto @@ -150,7 +150,7 @@ service ZITADELUsers { // Returns the user identified by the requested ID. rpc GetUser (GetUserRequest) returns (GetUserResponse) { option (google.api.http) = { - get: "/resources/v3alpha/users/{user_id}" + get: "/resources/v3alpha/users/{id}" }; option (zitadel.protoc_gen_zitadel.v2.options) = { @@ -208,7 +208,7 @@ service ZITADELUsers { // Patch an existing user with data based on a user schema. rpc PatchUser (PatchUserRequest) returns (PatchUserResponse) { option (google.api.http) = { - patch: "/resources/v3alpha/users/{user_id}" + patch: "/resources/v3alpha/users/{id}" body: "user" }; @@ -238,7 +238,7 @@ service ZITADELUsers { // The endpoint returns an error if the user is already in the state 'deactivated'. rpc DeactivateUser (DeactivateUserRequest) returns (DeactivateUserResponse) { option (google.api.http) = { - post: "/resources/v3alpha/users/{user_id}/_deactivate" + post: "/resources/v3alpha/users/{id}/_deactivate" }; option (zitadel.protoc_gen_zitadel.v2.options) = { @@ -257,15 +257,15 @@ service ZITADELUsers { }; } - // Reactivate a user + // Activate a user // - // Reactivate a previously deactivated user and change the state to 'active'. + // Activate a previously deactivated user and change the state to 'active'. // The user will be able to log in again. // // The endpoint returns an error if the user is not in the state 'deactivated'. - rpc ReactivateUser (ReactivateUserRequest) returns (ReactivateUserResponse) { + rpc ActivateUser (ActivateUserRequest) returns (ActivateUserResponse) { option (google.api.http) = { - post: "/resources/v3alpha/users/{user_id}/_reactivate" + post: "/resources/v3alpha/users/{id}/_activate" }; option (zitadel.protoc_gen_zitadel.v2.options) = { @@ -278,7 +278,7 @@ service ZITADELUsers { responses: { key: "200"; value: { - description: "User successfully reactivated"; + description: "User successfully activated"; }; }; }; @@ -294,7 +294,7 @@ service ZITADELUsers { // The endpoint returns an error if the user is already in the state 'locked'. rpc LockUser (LockUserRequest) returns (LockUserResponse) { option (google.api.http) = { - post: "/resources/v3alpha/users/{user_id}/_lock" + post: "/resources/v3alpha/users/{id}/_lock" }; option (zitadel.protoc_gen_zitadel.v2.options) = { @@ -321,7 +321,7 @@ service ZITADELUsers { // The endpoint returns an error if the user is not in the state 'locked'. rpc UnlockUser (UnlockUserRequest) returns (UnlockUserResponse) { option (google.api.http) = { - post: "/resources/v3alpha/users/{user_id}/_unlock" + post: "/resources/v3alpha/users/{id}/_unlock" }; option (zitadel.protoc_gen_zitadel.v2.options) = { @@ -346,7 +346,7 @@ service ZITADELUsers { // The user will be able to log in anymore. rpc DeleteUser (DeleteUserRequest) returns (DeleteUserResponse) { option (google.api.http) = { - delete: "/resources/v3alpha/users/{user_id}" + delete: "/resources/v3alpha/users/{id}" }; option (zitadel.protoc_gen_zitadel.v2.options) = { @@ -372,7 +372,7 @@ service ZITADELUsers { // which can be either returned or will be sent to the user by email. rpc SetContactEmail (SetContactEmailRequest) returns (SetContactEmailResponse) { option (google.api.http) = { - put: "/resources/v3alpha/users/{user_id}/email" + put: "/resources/v3alpha/users/{id}/email" body: "email" }; @@ -397,7 +397,7 @@ service ZITADELUsers { // Verify the contact email with the provided code. rpc VerifyContactEmail (VerifyContactEmailRequest) returns (VerifyContactEmailResponse) { option (google.api.http) = { - post: "/resources/v3alpha/users/{user_id}/email/_verify" + post: "/resources/v3alpha/users/{id}/email/_verify" body: "verification_code" }; @@ -422,7 +422,7 @@ service ZITADELUsers { // Resend the email with the verification code for the contact email address. rpc ResendContactEmailCode (ResendContactEmailCodeRequest) returns (ResendContactEmailCodeResponse) { option (google.api.http) = { - post: "/resources/v3alpha/users/{user_id}/email/_resend" + post: "/resources/v3alpha/users/{id}/email/_resend" body: "*" }; @@ -449,7 +449,7 @@ service ZITADELUsers { // which can be either returned or will be sent to the user by SMS. rpc SetContactPhone (SetContactPhoneRequest) returns (SetContactPhoneResponse) { option (google.api.http) = { - put: "/resources/v3alpha/users/{user_id}/phone" + put: "/resources/v3alpha/users/{id}/phone" body: "phone" }; @@ -474,7 +474,7 @@ service ZITADELUsers { // Verify the contact phone with the provided code. rpc VerifyContactPhone (VerifyContactPhoneRequest) returns (VerifyContactPhoneResponse) { option (google.api.http) = { - post: "/resources/v3alpha/users/{user_id}/phone/_verify" + post: "/resources/v3alpha/users/{id}/phone/_verify" body: "verification_code" }; @@ -499,7 +499,7 @@ service ZITADELUsers { // Resend the phone with the verification code for the contact phone number. rpc ResendContactPhoneCode (ResendContactPhoneCodeRequest) returns (ResendContactPhoneCodeResponse) { option (google.api.http) = { - post: "/resources/v3alpha/users/{user_id}/phone/_resend" + post: "/resources/v3alpha/users/{id}/phone/_resend" body: "*" }; @@ -524,7 +524,7 @@ service ZITADELUsers { // Add a new unique username to a user. The username will be used to identify the user on authentication. rpc AddUsername (AddUsernameRequest) returns (AddUsernameResponse) { option (google.api.http) = { - post: "/resources/v3alpha/users/{user_id}/username" + post: "/resources/v3alpha/users/{id}/username" body: "username" }; @@ -549,7 +549,7 @@ service ZITADELUsers { // Remove an existing username of a user, so it cannot be used for authentication anymore. rpc RemoveUsername (RemoveUsernameRequest) returns (RemoveUsernameResponse) { option (google.api.http) = { - delete: "/resources/v3alpha/users/{user_id}/username/{username_id}" + delete: "/resources/v3alpha/users/{id}/username/{username_id}" }; option (zitadel.protoc_gen_zitadel.v2.options) = { @@ -573,7 +573,7 @@ service ZITADELUsers { // Add, update or reset a user's password with either a verification code or the current password. rpc SetPassword (SetPasswordRequest) returns (SetPasswordResponse) { option (google.api.http) = { - post: "/resources/v3alpha/users/{user_id}/password" + post: "/resources/v3alpha/users/{id}/password" body: "new_password" }; @@ -598,7 +598,7 @@ service ZITADELUsers { // Request a code to be able to set a new password. rpc RequestPasswordReset (RequestPasswordResetRequest) returns (RequestPasswordResetResponse) { option (google.api.http) = { - post: "/resources/v3alpha/users/{user_id}/password/_reset" + post: "/resources/v3alpha/users/{id}/password/_reset" body: "*" }; @@ -625,7 +625,7 @@ service ZITADELUsers { // which are used to verify the device. rpc StartWebAuthNRegistration (StartWebAuthNRegistrationRequest) returns (StartWebAuthNRegistrationResponse) { option (google.api.http) = { - post: "/resources/v3alpha/users/{user_id}/webauthn" + post: "/resources/v3alpha/users/{id}/webauthn" body: "registration" }; @@ -650,7 +650,7 @@ service ZITADELUsers { // Verify the WebAuthN registration started by StartWebAuthNRegistration with the public key credential. rpc VerifyWebAuthNRegistration (VerifyWebAuthNRegistrationRequest) returns (VerifyWebAuthNRegistrationResponse) { option (google.api.http) = { - post: "/resources/v3alpha/users/{user_id}/webauthn/{web_auth_n_id}/_verify" + post: "/resources/v3alpha/users/{id}/webauthn/{web_auth_n_id}/_verify" body: "verify" }; @@ -675,7 +675,7 @@ service ZITADELUsers { // The code will allow the user to start a new WebAuthN registration. rpc CreateWebAuthNRegistrationLink (CreateWebAuthNRegistrationLinkRequest) returns (CreateWebAuthNRegistrationLinkResponse) { option (google.api.http) = { - post: "/resources/v3alpha/users/{user_id}/webauthn/registration_link" + post: "/resources/v3alpha/users/{id}/webauthn/registration_link" body: "*" }; @@ -699,7 +699,7 @@ service ZITADELUsers { // Remove an existing WebAuthN authenticator from a user, so it cannot be used for authentication anymore. rpc RemoveWebAuthNAuthenticator (RemoveWebAuthNAuthenticatorRequest) returns (RemoveWebAuthNAuthenticatorResponse) { option (google.api.http) = { - delete: "/resources/v3alpha/users/{user_id}/webauthn/{web_auth_n_id}" + delete: "/resources/v3alpha/users/{id}/webauthn/{web_auth_n_id}" }; option (zitadel.protoc_gen_zitadel.v2.options) = { @@ -723,7 +723,7 @@ service ZITADELUsers { // As a response a secret is returned, which is used to initialize a TOTP app or device. rpc StartTOTPRegistration (StartTOTPRegistrationRequest) returns (StartTOTPRegistrationResponse) { option (google.api.http) = { - post: "/resources/v3alpha/users/{user_id}/totp" + post: "/resources/v3alpha/users/{id}/totp" }; option (zitadel.protoc_gen_zitadel.v2.options) = { @@ -746,7 +746,7 @@ service ZITADELUsers { // Verify the time-based one-time-password (TOTP) registration with the generated code. rpc VerifyTOTPRegistration (VerifyTOTPRegistrationRequest) returns (VerifyTOTPRegistrationResponse) { option (google.api.http) = { - post: "/resources/v3alpha/users/{user_id}/totp/{totp_id}/_verify" + post: "/resources/v3alpha/users/{id}/totp/{totp_id}/_verify" body: "code" }; @@ -770,7 +770,7 @@ service ZITADELUsers { // Remove an existing time-based one-time-password (TOTP) authenticator from a user, so it cannot be used for authentication anymore. rpc RemoveTOTPAuthenticator (RemoveTOTPAuthenticatorRequest) returns (RemoveTOTPAuthenticatorResponse) { option (google.api.http) = { - delete: "/resources/v3alpha/users/{user_id}/totp/{totp_id}" + delete: "/resources/v3alpha/users/{id}/totp/{totp_id}" }; option (zitadel.protoc_gen_zitadel.v2.options) = { @@ -795,7 +795,7 @@ service ZITADELUsers { // which can be either returned or will be sent to the user by SMS. rpc AddOTPSMSAuthenticator (AddOTPSMSAuthenticatorRequest) returns (AddOTPSMSAuthenticatorResponse) { option (google.api.http) = { - post: "/resources/v3alpha/users/{user_id}/otp_sms" + post: "/resources/v3alpha/users/{id}/otp_sms" body: "phone" }; @@ -819,7 +819,7 @@ service ZITADELUsers { // Verify the OTP SMS registration with the provided code. rpc VerifyOTPSMSRegistration (VerifyOTPSMSRegistrationRequest) returns (VerifyOTPSMSRegistrationResponse) { option (google.api.http) = { - post: "/resources/v3alpha/users/{user_id}/otp_sms/{otp_sms_id}/_verify" + post: "/resources/v3alpha/users/{id}/otp_sms/{otp_sms_id}/_verify" body: "code" }; @@ -844,7 +844,7 @@ service ZITADELUsers { // Remove an existing one-time-password (OTP) SMS authenticator from a user, so it cannot be used for authentication anymore. rpc RemoveOTPSMSAuthenticator (RemoveOTPSMSAuthenticatorRequest) returns (RemoveOTPSMSAuthenticatorResponse) { option (google.api.http) = { - delete: "/resources/v3alpha/users/{user_id}/otp_sms/{otp_sms_id}" + delete: "/resources/v3alpha/users/{id}/otp_sms/{otp_sms_id}" }; option (zitadel.protoc_gen_zitadel.v2.options) = { @@ -869,7 +869,7 @@ service ZITADELUsers { // which can be either returned or will be sent to the user by email. rpc AddOTPEmailAuthenticator (AddOTPEmailAuthenticatorRequest) returns (AddOTPEmailAuthenticatorResponse) { option (google.api.http) = { - post: "/resources/v3alpha/users/{user_id}/otp_email" + post: "/resources/v3alpha/users/{id}/otp_email" body: "email" }; @@ -893,7 +893,7 @@ service ZITADELUsers { // Verify the OTP Email registration with the provided code. rpc VerifyOTPEmailRegistration (VerifyOTPEmailRegistrationRequest) returns (VerifyOTPEmailRegistrationResponse) { option (google.api.http) = { - post: "/resources/v3alpha/users/{user_id}/otp_email/{otp_email_id}/_verify" + post: "/resources/v3alpha/users/{id}/otp_email/{otp_email_id}/_verify" body: "code" }; @@ -918,7 +918,7 @@ service ZITADELUsers { // Remove an existing one-time-password (OTP) Email authenticator from a user, so it cannot be used for authentication anymore. rpc RemoveOTPEmailAuthenticator (RemoveOTPEmailAuthenticatorRequest) returns (RemoveOTPEmailAuthenticatorResponse) { option (google.api.http) = { - delete: "/resources/v3alpha/users/{user_id}/otp_email/{otp_email_id}" + delete: "/resources/v3alpha/users/{id}/otp_email/{otp_email_id}" }; option (zitadel.protoc_gen_zitadel.v2.options) = { @@ -991,7 +991,7 @@ service ZITADELUsers { // This will allow the user to authenticate with the provided IDP. rpc AddIDPAuthenticator (AddIDPAuthenticatorRequest) returns (AddIDPAuthenticatorResponse) { option (google.api.http) = { - post: "/resources/v3alpha/users/{user_id}/idps" + post: "/resources/v3alpha/users/{id}/idps" body: "authenticator" }; @@ -1016,7 +1016,7 @@ service ZITADELUsers { // Remove an existing identity provider (IDP) authenticator from a user, so it cannot be used for authentication anymore. rpc RemoveIDPAuthenticator (RemoveIDPAuthenticatorRequest) returns (RemoveIDPAuthenticatorResponse) { option (google.api.http) = { - delete: "/resources/v3alpha/users/{user_id}/idps/{idp_id}" + delete: "/resources/v3alpha/users/{id}/idps/{idp_id}" }; option (zitadel.protoc_gen_zitadel.v2.options) = { @@ -1069,7 +1069,7 @@ message GetUserRequest { } ]; // unique identifier of the user. - string user_id = 2 [ + string id = 2 [ (validate.rules).string = {min_len: 1, max_len: 200}, (google.api.field_behavior) = REQUIRED, (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -1123,7 +1123,7 @@ message PatchUserRequest { // Optionally expect the user to be in this organization. optional zitadel.object.v3alpha.Organization organization = 2; // unique identifier of the user. - string user_id = 3 [ + string id = 3 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "\"69629012906488334\""; } @@ -1156,7 +1156,7 @@ message DeactivateUserRequest { // Optionally expect the user to be in this organization. optional zitadel.object.v3alpha.Organization organization = 2; // unique identifier of the user. - string user_id = 3 [ + string id = 3 [ (validate.rules).string = {min_len: 1, max_len: 200}, (google.api.field_behavior) = REQUIRED, (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -1172,7 +1172,7 @@ message DeactivateUserResponse { } -message ReactivateUserRequest { +message ActivateUserRequest { optional zitadel.object.v3alpha.Instance instance = 1 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { default: "\"domain from HOST or :authority header\"" @@ -1181,7 +1181,7 @@ message ReactivateUserRequest { // Optionally expect the user to be in this organization. optional zitadel.object.v3alpha.Organization organization = 2; // unique identifier of the user. - string user_id = 3 [ + string id = 3 [ (validate.rules).string = {min_len: 1, max_len: 200}, (google.api.field_behavior) = REQUIRED, (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -1192,7 +1192,7 @@ message ReactivateUserRequest { ]; } -message ReactivateUserResponse { +message ActivateUserResponse { zitadel.resources.object.v3alpha.Details details = 1; } @@ -1205,7 +1205,7 @@ message LockUserRequest { // Optionally expect the user to be in this organization. optional zitadel.object.v3alpha.Organization organization = 2; // unique identifier of the user. - string user_id = 3 [ + string id = 3 [ (validate.rules).string = {min_len: 1, max_len: 200}, (google.api.field_behavior) = REQUIRED, (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -1229,7 +1229,7 @@ message UnlockUserRequest { // Optionally expect the user to be in this organization. optional zitadel.object.v3alpha.Organization organization = 2; // unique identifier of the user. - string user_id = 3 [ + string id = 3 [ (validate.rules).string = {min_len: 1, max_len: 200}, (google.api.field_behavior) = REQUIRED, (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -1253,7 +1253,7 @@ message DeleteUserRequest { // Optionally expect the user to be in this organization. optional zitadel.object.v3alpha.Organization organization = 2; // unique identifier of the user. - string user_id = 3 [ + string id = 3 [ (validate.rules).string = {min_len: 1, max_len: 200}, (google.api.field_behavior) = REQUIRED, (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -1277,7 +1277,7 @@ message SetContactEmailRequest { // Optionally expect the user to be in this organization. optional zitadel.object.v3alpha.Organization organization = 2; // unique identifier of the user. - string user_id = 3 [ + string id = 3 [ (validate.rules).string = {min_len: 1, max_len: 200}, (google.api.field_behavior) = REQUIRED, (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -1309,7 +1309,7 @@ message VerifyContactEmailRequest { // Optionally expect the user to be in this organization. optional zitadel.object.v3alpha.Organization organization = 2; // unique identifier of the user. - string user_id = 3 [ + string id = 3 [ (validate.rules).string = {min_len: 1, max_len: 200}, (google.api.field_behavior) = REQUIRED, (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -1343,7 +1343,7 @@ message ResendContactEmailCodeRequest { // Optionally expect the user to be in this organization. optional zitadel.object.v3alpha.Organization organization = 2; // unique identifier of the user. - string user_id = 3 [ + string id = 3 [ (validate.rules).string = {min_len: 1, max_len: 200}, (google.api.field_behavior) = REQUIRED, (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -1376,7 +1376,7 @@ message SetContactPhoneRequest { // Optionally expect the user to be in this organization. optional zitadel.object.v3alpha.Organization organization = 2; // unique identifier of the user. - string user_id = 3 [ + string id = 3 [ (validate.rules).string = {min_len: 1, max_len: 200}, (google.api.field_behavior) = REQUIRED, (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -1408,7 +1408,7 @@ message VerifyContactPhoneRequest { // Optionally expect the user to be in this organization. optional zitadel.object.v3alpha.Organization organization = 2; // unique identifier of the user. - string user_id = 3 [ + string id = 3 [ (validate.rules).string = {min_len: 1, max_len: 200}, (google.api.field_behavior) = REQUIRED, (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -1442,7 +1442,7 @@ message ResendContactPhoneCodeRequest { // Optionally expect the user to be in this organization. optional zitadel.object.v3alpha.Organization organization = 2; // unique identifier of the user. - string user_id = 3 [ + string id = 3 [ (validate.rules).string = {min_len: 1, max_len: 200}, (google.api.field_behavior) = REQUIRED, (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -1475,7 +1475,7 @@ message AddUsernameRequest { // Optionally expect the user to be in this organization. optional zitadel.object.v3alpha.Organization organization = 2; // unique identifier of the user. - string user_id = 3 [ + string id = 3 [ (validate.rules).string = {min_len: 1, max_len: 200}, (google.api.field_behavior) = REQUIRED, (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -1507,7 +1507,7 @@ message RemoveUsernameRequest { // Optionally expect the user to be in this organization. optional zitadel.object.v3alpha.Organization organization = 2; // unique identifier of the user. - string user_id = 3 [ + string id = 3 [ (validate.rules).string = {min_len: 1, max_len: 200}, (google.api.field_behavior) = REQUIRED, (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -1541,7 +1541,7 @@ message SetPasswordRequest { // Optionally expect the user to be in this organization. optional zitadel.object.v3alpha.Organization organization = 2; // unique identifier of the user. - string user_id = 3 [ + string id = 3 [ (validate.rules).string = {min_len: 1, max_len: 200}, (google.api.field_behavior) = REQUIRED, (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -1567,7 +1567,7 @@ message RequestPasswordResetRequest { // Optionally expect the user to be in this organization. optional zitadel.object.v3alpha.Organization organization = 2; // unique identifier of the user. - string user_id = 3 [ + string id = 3 [ (validate.rules).string = {min_len: 1, max_len: 200}, (google.api.field_behavior) = REQUIRED, (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -1606,7 +1606,7 @@ message StartWebAuthNRegistrationRequest { // Optionally expect the user to be in this organization. optional zitadel.object.v3alpha.Organization organization = 2; // unique identifier of the user. - string user_id = 3 [ + string id = 3 [ (validate.rules).string = {min_len: 1, max_len: 200}, (google.api.field_behavior) = REQUIRED, (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -1645,7 +1645,7 @@ message VerifyWebAuthNRegistrationRequest { // Optionally expect the user to be in this organization. optional zitadel.object.v3alpha.Organization organization = 2; // unique identifier of the user. - string user_id = 3 [ + string id = 3 [ (validate.rules).string = {min_len: 1, max_len: 200}, (google.api.field_behavior) = REQUIRED, (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -1680,7 +1680,7 @@ message CreateWebAuthNRegistrationLinkRequest { // Optionally expect the user to be in this organization. optional zitadel.object.v3alpha.Organization organization = 2; // unique identifier of the user. - string user_id = 3 [ + string id = 3 [ (validate.rules).string = {min_len: 1, max_len: 200}, (google.api.field_behavior) = REQUIRED, (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -1713,7 +1713,7 @@ message RemoveWebAuthNAuthenticatorRequest { // Optionally expect the user to be in this organization. optional zitadel.object.v3alpha.Organization organization = 2; // unique identifier of the user. - string user_id = 3 [ + string id = 3 [ (validate.rules).string = {min_len: 1, max_len: 200}, (google.api.field_behavior) = REQUIRED, (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -1747,7 +1747,7 @@ message StartTOTPRegistrationRequest { // Optionally expect the user to be in this organization. optional zitadel.object.v3alpha.Organization organization = 2; // unique identifier of the user. - string user_id = 3 [ + string id = 3 [ (validate.rules).string = {min_len: 1, max_len: 200}, (google.api.field_behavior) = REQUIRED, (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -1789,7 +1789,7 @@ message VerifyTOTPRegistrationRequest { // Optionally expect the user to be in this organization. optional zitadel.object.v3alpha.Organization organization = 2; // unique identifier of the user. - string user_id = 3 [ + string id = 3 [ (validate.rules).string = {min_len: 1, max_len: 200}, (google.api.field_behavior) = REQUIRED, (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -1833,7 +1833,7 @@ message RemoveTOTPAuthenticatorRequest { // Optionally expect the user to be in this organization. optional zitadel.object.v3alpha.Organization organization = 2; // unique identifier of the user. - string user_id = 3 [ + string id = 3 [ (validate.rules).string = {min_len: 1, max_len: 200}, (google.api.field_behavior) = REQUIRED, (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -1867,7 +1867,7 @@ message AddOTPSMSAuthenticatorRequest { // Optionally expect the user to be in this organization. optional zitadel.object.v3alpha.Organization organization = 2; // unique identifier of the user. - string user_id = 3 [ + string id = 3 [ (validate.rules).string = {min_len: 1, max_len: 200}, (google.api.field_behavior) = REQUIRED, (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -1906,7 +1906,7 @@ message VerifyOTPSMSRegistrationRequest { // Optionally expect the user to be in this organization. optional zitadel.object.v3alpha.Organization organization = 2; // unique identifier of the user. - string user_id = 3 [ + string id = 3 [ (validate.rules).string = {min_len: 1, max_len: 200}, (google.api.field_behavior) = REQUIRED, (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -1950,7 +1950,7 @@ message RemoveOTPSMSAuthenticatorRequest { // Optionally expect the user to be in this organization. optional zitadel.object.v3alpha.Organization organization = 2; // unique identifier of the user. - string user_id = 3 [ + string id = 3 [ (validate.rules).string = {min_len: 1, max_len: 200}, (google.api.field_behavior) = REQUIRED, (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -1984,7 +1984,7 @@ message AddOTPEmailAuthenticatorRequest { // Optionally expect the user to be in this organization. optional zitadel.object.v3alpha.Organization organization = 2; // unique identifier of the user. - string user_id = 3 [ + string id = 3 [ (validate.rules).string = {min_len: 1, max_len: 200}, (google.api.field_behavior) = REQUIRED, (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -2022,7 +2022,7 @@ message VerifyOTPEmailRegistrationRequest { // Optionally expect the user to be in this organization. optional zitadel.object.v3alpha.Organization organization = 2; // unique identifier of the user. - string user_id = 3 [ + string id = 3 [ (validate.rules).string = {min_len: 1, max_len: 200}, (google.api.field_behavior) = REQUIRED, (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -2066,7 +2066,7 @@ message RemoveOTPEmailAuthenticatorRequest { // Optionally expect the user to be in this organization. optional zitadel.object.v3alpha.Organization organization = 2; // unique identifier of the user. - string user_id = 3 [ + string id = 3 [ (validate.rules).string = {min_len: 1, max_len: 200}, (google.api.field_behavior) = REQUIRED, (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -2170,7 +2170,7 @@ message GetIdentityProviderIntentResponse { // and detailed / profile information. IDPInformation idp_information = 2; // If the user was already federated and linked to a ZITADEL user, it's id will be returned. - optional string user_id = 3 [ + optional string id = 3 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "\"163840776835432345\""; } @@ -2186,7 +2186,7 @@ message AddIDPAuthenticatorRequest { // Optionally expect the user to be in this organization. optional zitadel.object.v3alpha.Organization organization = 2; // unique identifier of the user. - string user_id = 3 [ + string id = 3 [ (validate.rules).string = {min_len: 1, max_len: 200}, (google.api.field_behavior) = REQUIRED, (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -2211,7 +2211,7 @@ message RemoveIDPAuthenticatorRequest { // Optionally expect the user to be in this organization. optional zitadel.object.v3alpha.Organization organization = 2; // unique identifier of the user. - string user_id = 3 [ + string id = 3 [ (validate.rules).string = {min_len: 1, max_len: 200}, (google.api.field_behavior) = REQUIRED, (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { From 77aa02a5216430bddc1da99a6596b328ea2befad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=B6hlmann?= Date: Tue, 17 Sep 2024 13:08:13 +0300 Subject: [PATCH 02/19] fix(projection): increase transaction duration (#8632) # Which Problems Are Solved Reduce the chance for projection dead-locks. Increasing or disabling the projection transaction duration solved dead-locks in all reported cases. # How the Problems Are Solved Increase the default transaction duration to 1 minute. Due to the high value it is functionally similar to disabling, however it still provides a safety net for transaction that do freeze, perhaps due to connection issues with the database. # Additional Changes - Integration test uses default. - Technical advisory # Additional Context - Related to https://github.com/zitadel/zitadel/issues/8517 --------- Co-authored-by: Silvan --- cmd/defaults.yaml | 5 +- docs/docs/support/advisory/a10012.md | 60 ++++++++++++++++++++++++ internal/integration/config/zitadel.yaml | 1 - 3 files changed, 61 insertions(+), 5 deletions(-) create mode 100644 docs/docs/support/advisory/a10012.md diff --git a/cmd/defaults.yaml b/cmd/defaults.yaml index a81a1ff126..5da59fa0d0 100644 --- a/cmd/defaults.yaml +++ b/cmd/defaults.yaml @@ -231,7 +231,7 @@ Projections: # The maximum duration a transaction remains open # before it spots left folding additional events # and updates the table. - TransactionDuration: 500ms # ZITADEL_PROJECTIONS_TRANSACTIONDURATION + TransactionDuration: 1m # ZITADEL_PROJECTIONS_TRANSACTIONDURATION # Time interval between scheduled projections RequeueEvery: 60s # ZITADEL_PROJECTIONS_REQUEUEEVERY # Time between retried database statements resulting from projected events @@ -246,10 +246,7 @@ Projections: HandleActiveInstances: 0s # ZITADEL_PROJECTIONS_HANDLEACTIVEINSTANCES # In the Customizations section, all settings from above can be overwritten for each specific projection Customizations: - Projects: - TransactionDuration: 2s custom_texts: - TransactionDuration: 2s BulkLimit: 400 project_grant_fields: TransactionDuration: 0s diff --git a/docs/docs/support/advisory/a10012.md b/docs/docs/support/advisory/a10012.md new file mode 100644 index 0000000000..fd08a9afd6 --- /dev/null +++ b/docs/docs/support/advisory/a10012.md @@ -0,0 +1,60 @@ +--- +title: Technical Advisory 10012 +--- + +## Date and Version + +Version: 2.63.0 + +Date: 2024-09-26 + +## Description + +In version 2.63.0 we've increased the transaction duration for projections. + +ZITADEL has an event driven architecture. After events are pushed to the eventstore, +they are reduced into projections in bulk batches. Projections allow for efficient lookup of data through normalized SQL tables. + +We've investigated multiple reports of outdated projections. +For example created users missing in get requests, or missing data after a ZITADEL upgrade[^1]. +The conclusion is that the transaction in which we perform a bulk of queries can timeout. +The old setting defined a transaction duration of 500ms for a bulk of 200 events. +A single event may create multiple statements in a single projection. +A timeout may occur even if the actual bulk size is less than 200, +which then results in more back-pressure on a busy system, leading to more timeouts and effectively dead-locking a projection. + +Increasing or disabling the projection transaction duration solved dead-locks in all reported cases. +We've decided to increase the transaction duration to 1 minute. +Due to the high value it is functionally similar to disabling, +however it still provides a safety net for transaction that do freeze, +perhaps due to connection issues with the database. + +[^1]: Changes written to the eventstore are the main source of truth. When a projection is out of date, some request may serve incomplete or no data. The data itself is however not lost. + +## Statement + +A summary of bug reports can be found in the following issue: [Missing data due to outdated projections](https://github.com/zitadel/zitadel/issues/8517). +This change was submitted in the following PR: +[fix(projection): increase transaction duration](https://github.com/zitadel/zitadel/pull/8632), which will be released in Version [2.63.0](https://github.com/zitadel/zitadel/releases/tag/v2.63.0) + +## Mitigation + +If you have a custom configuration for projections, this update will not apply to your system or some projections. When encountering projection dead-lock consider increasing the timeout to the new default value. + +Note that entries under `Customizations` overwrite the global settings for a single projection. + +```yaml +Projections: + TransactionDuration: 1m # ZITADEL_PROJECTIONS_TRANSACTIONDURATION + BulkLimit: 200 # ZITADEL_PROJECTIONS_BULKLIMIT + Customizations: + custom_texts: + BulkLimit: 400 + project_grant_fields: + TransactionDuration: 0s + BulkLimit: 2000 +``` + +## Impact + +Once this update has been released and deployed, transactions are allowed to run longer. No other functional impact is expected. diff --git a/internal/integration/config/zitadel.yaml b/internal/integration/config/zitadel.yaml index 68e0b43f9c..6524b4806f 100644 --- a/internal/integration/config/zitadel.yaml +++ b/internal/integration/config/zitadel.yaml @@ -33,7 +33,6 @@ LogStore: Projections: HandleActiveInstances: 30m RequeueEvery: 5s - TransactionDuration: 1m Customizations: NotificationsQuotas: RequeueEvery: 1s From d01bd1c51aa41ead46edc6760e18782f8e656d87 Mon Sep 17 00:00:00 2001 From: Livio Spring Date: Tue, 17 Sep 2024 13:34:14 +0200 Subject: [PATCH 03/19] fix: correctly check app state on authentication (#8630) # Which Problems Are Solved In Zitadel, even after an organization is deactivated, associated projects, respectively their applications remain active. Users across other organizations can still log in and access through these applications, leading to unauthorized access. Additionally, if a project was deactivated access to applications was also still possible. # How the Problems Are Solved - Correctly check the status of the organization and related project. (Corresponding functions have been renamed to `Active...`) --- internal/api/oidc/client.go | 12 +- .../api/oidc/integration_test/client_test.go | 12 + internal/api/oidc/introspect.go | 2 +- internal/api/saml/storage.go | 10 +- internal/integration/oidc.go | 14 ++ internal/query/app.go | 217 ++++++++++-------- internal/query/app_test.go | 156 +++++++++++-- internal/query/introspection.go | 2 +- internal/query/introspection_client_by_id.sql | 5 +- internal/query/introspection_test.go | 4 +- internal/query/oidc_client.go | 2 +- internal/query/oidc_client_by_id.sql | 5 +- internal/query/oidc_client_test.go | 4 +- 13 files changed, 299 insertions(+), 146 deletions(-) diff --git a/internal/api/oidc/client.go b/internal/api/oidc/client.go index 41fe19cb4a..6bca54c671 100644 --- a/internal/api/oidc/client.go +++ b/internal/api/oidc/client.go @@ -50,13 +50,10 @@ func (o *OPStorage) GetClientByClientID(ctx context.Context, id string) (_ op.Cl err = oidcError(err) span.EndWithError(err) }() - client, err := o.query.GetOIDCClientByID(ctx, id, false) + client, err := o.query.ActiveOIDCClientByID(ctx, id, false) if err != nil { return nil, err } - if client.State != domain.AppStateActive { - return nil, zerrors.ThrowPreconditionFailed(nil, "OIDC-sdaGg", "client is not active") - } return ClientFromBusiness(client, o.defaultLoginURL, o.defaultLoginURLV2), nil } @@ -979,16 +976,13 @@ func (s *Server) VerifyClient(ctx context.Context, r *op.Request[op.ClientCreden if err != nil { return nil, err } - client, err := s.query.GetOIDCClientByID(ctx, clientID, assertion) + client, err := s.query.ActiveOIDCClientByID(ctx, clientID, assertion) if zerrors.IsNotFound(err) { - return nil, oidc.ErrInvalidClient().WithParent(err).WithReturnParentToClient(authz.GetFeatures(ctx).DebugOIDCParentError).WithDescription("client not found") + return nil, oidc.ErrInvalidClient().WithParent(err).WithReturnParentToClient(authz.GetFeatures(ctx).DebugOIDCParentError).WithDescription("no active client not found") } if err != nil { return nil, err // defaults to server error } - if client.State != domain.AppStateActive { - return nil, oidc.ErrInvalidClient().WithDescription("client is not active") - } if client.Settings == nil { client.Settings = &query.OIDCSettings{ AccessTokenLifetime: s.defaultAccessTokenLifetime, diff --git a/internal/api/oidc/integration_test/client_test.go b/internal/api/oidc/integration_test/client_test.go index 7e9f15ffac..1b9ccd5cb3 100644 --- a/internal/api/oidc/integration_test/client_test.go +++ b/internal/api/oidc/integration_test/client_test.go @@ -196,9 +196,13 @@ func TestServer_VerifyClient(t *testing.T) { sessionID, sessionToken, startTime, changeTime := Instance.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId()) project, err := Instance.CreateProject(CTX) require.NoError(t, err) + projectInactive, err := Instance.CreateProject(CTX) + require.NoError(t, err) inactiveClient, err := Instance.CreateOIDCInactivateClient(CTX, redirectURI, logoutRedirectURI, project.GetId()) require.NoError(t, err) + inactiveProjectClient, err := Instance.CreateOIDCInactivateProjectClient(CTX, redirectURI, logoutRedirectURI, projectInactive.GetId()) + require.NoError(t, err) nativeClient, err := Instance.CreateOIDCNativeClient(CTX, redirectURI, logoutRedirectURI, project.GetId(), false) require.NoError(t, err) basicWebClient, err := Instance.CreateOIDCWebClientBasic(CTX, redirectURI, logoutRedirectURI, project.GetId()) @@ -240,6 +244,14 @@ func TestServer_VerifyClient(t *testing.T) { }, wantErr: true, }, + { + name: "client inactive (project) error", + client: clientDetails{ + authReqClientID: nativeClient.GetClientId(), + clientID: inactiveProjectClient.GetClientId(), + }, + wantErr: true, + }, { name: "native client success", client: clientDetails{ diff --git a/internal/api/oidc/introspect.go b/internal/api/oidc/introspect.go index 868676c1f1..c028013d6a 100644 --- a/internal/api/oidc/introspect.go +++ b/internal/api/oidc/introspect.go @@ -213,7 +213,7 @@ func (s *Server) clientFromCredentials(ctx context.Context, cc *op.ClientCredent if err != nil { return nil, err } - client, err = s.query.GetIntrospectionClientByID(ctx, clientID, assertion) + client, err = s.query.ActiveIntrospectionClientByID(ctx, clientID, assertion) if errors.Is(err, sql.ErrNoRows) { return nil, oidc.ErrUnauthorizedClient().WithParent(err).WithReturnParentToClient(authz.GetFeatures(ctx).DebugOIDCParentError) } diff --git a/internal/api/saml/storage.go b/internal/api/saml/storage.go index ca523398f7..8791619ba0 100644 --- a/internal/api/saml/storage.go +++ b/internal/api/saml/storage.go @@ -55,13 +55,10 @@ type Storage struct { } func (p *Storage) GetEntityByID(ctx context.Context, entityID string) (*serviceprovider.ServiceProvider, error) { - app, err := p.query.AppBySAMLEntityID(ctx, entityID) + app, err := p.query.ActiveAppBySAMLEntityID(ctx, entityID) if err != nil { return nil, err } - if app.State != domain.AppStateActive { - return nil, zerrors.ThrowPreconditionFailed(nil, "SAML-sdaGg", "app is not active") - } return serviceprovider.NewServiceProvider( app.ID, &serviceprovider.Config{ @@ -72,13 +69,10 @@ func (p *Storage) GetEntityByID(ctx context.Context, entityID string) (*servicep } func (p *Storage) GetEntityIDByAppID(ctx context.Context, appID string) (string, error) { - app, err := p.query.AppByID(ctx, appID) + app, err := p.query.AppByID(ctx, appID, true) if err != nil { return "", err } - if app.State != domain.AppStateActive { - return "", zerrors.ThrowPreconditionFailed(nil, "SAML-sdaGg", "app is not active") - } return app.SAMLConfig.EntityID, nil } diff --git a/internal/integration/oidc.go b/internal/integration/oidc.go index 9f394e2c2c..3afd262a35 100644 --- a/internal/integration/oidc.go +++ b/internal/integration/oidc.go @@ -107,6 +107,20 @@ func (i *Instance) CreateOIDCInactivateClient(ctx context.Context, redirectURI, return client, err } +func (i *Instance) CreateOIDCInactivateProjectClient(ctx context.Context, redirectURI, logoutRedirectURI, projectID string) (*management.AddOIDCAppResponse, error) { + client, err := i.CreateOIDCNativeClient(ctx, redirectURI, logoutRedirectURI, projectID, false) + if err != nil { + return nil, err + } + _, err = i.Client.Mgmt.DeactivateProject(ctx, &management.DeactivateProjectRequest{ + Id: projectID, + }) + if err != nil { + return nil, err + } + return client, err +} + func (i *Instance) CreateOIDCImplicitFlowClient(ctx context.Context, redirectURI string) (*management.AddOIDCAppResponse, error) { project, err := i.Client.Mgmt.AddProject(ctx, &management.AddProjectRequest{ Name: fmt.Sprintf("project-%d", time.Now().UnixNano()), diff --git a/internal/query/app.go b/internal/query/app.go index 7d69981dd4..b94cb9cdaf 100644 --- a/internal/query/app.go +++ b/internal/query/app.go @@ -256,7 +256,7 @@ func (q *Queries) AppByProjectAndAppID(ctx context.Context, shouldTriggerBulk bo traceSpan.EndWithError(err) } - stmt, scan := prepareAppQuery(ctx, q.client) + stmt, scan := prepareAppQuery(ctx, q.client, false) eq := sq.Eq{ AppColumnID.identifier(): appID, AppColumnProjectID.identifier(): projectID, @@ -274,15 +274,20 @@ func (q *Queries) AppByProjectAndAppID(ctx context.Context, shouldTriggerBulk bo return app, err } -func (q *Queries) AppByID(ctx context.Context, appID string) (app *App, err error) { +func (q *Queries) AppByID(ctx context.Context, appID string, activeOnly bool) (app *App, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() - stmt, scan := prepareAppQuery(ctx, q.client) + stmt, scan := prepareAppQuery(ctx, q.client, activeOnly) eq := sq.Eq{ AppColumnID.identifier(): appID, AppColumnInstanceID.identifier(): authz.GetInstance(ctx).InstanceID(), } + if activeOnly { + eq[AppColumnState.identifier()] = domain.AppStateActive + eq[ProjectColumnState.identifier()] = domain.ProjectStateActive + eq[OrgColumnState.identifier()] = domain.OrgStateActive + } query, args, err := stmt.Where(eq).ToSql() if err != nil { return nil, zerrors.ThrowInternal(err, "QUERY-immt9", "Errors.Query.SQLStatement") @@ -295,7 +300,7 @@ func (q *Queries) AppByID(ctx context.Context, appID string) (app *App, err erro return app, err } -func (q *Queries) AppBySAMLEntityID(ctx context.Context, entityID string) (app *App, err error) { +func (q *Queries) ActiveAppBySAMLEntityID(ctx context.Context, entityID string) (app *App, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() @@ -303,6 +308,9 @@ func (q *Queries) AppBySAMLEntityID(ctx context.Context, entityID string) (app * eq := sq.Eq{ AppSAMLConfigColumnEntityID.identifier(): entityID, AppColumnInstanceID.identifier(): authz.GetInstance(ctx).InstanceID(), + AppColumnState.identifier(): domain.AppStateActive, + ProjectColumnState.identifier(): domain.ProjectStateActive, + OrgColumnState.identifier(): domain.OrgStateActive, } query, args, err := stmt.Where(eq).ToSql() if err != nil { @@ -413,8 +421,13 @@ func (q *Queries) AppByClientID(ctx context.Context, clientID string) (app *App, ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() - stmt, scan := prepareAppQuery(ctx, q.client) - eq := sq.Eq{AppColumnInstanceID.identifier(): authz.GetInstance(ctx).InstanceID()} + stmt, scan := prepareAppQuery(ctx, q.client, true) + eq := sq.Eq{ + AppColumnInstanceID.identifier(): authz.GetInstance(ctx).InstanceID(), + AppColumnState.identifier(): domain.AppStateActive, + ProjectColumnState.identifier(): domain.ProjectStateActive, + OrgColumnState.identifier(): domain.OrgStateActive, + } query, args, err := stmt.Where(sq.And{ eq, sq.Or{ @@ -491,107 +504,121 @@ func NewAppProjectIDSearchQuery(id string) (SearchQuery, error) { return NewTextQuery(AppColumnProjectID, id, TextEquals) } -func prepareAppQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuilder, func(*sql.Row) (*App, error)) { - return sq.Select( - AppColumnID.identifier(), - AppColumnName.identifier(), - AppColumnProjectID.identifier(), - AppColumnCreationDate.identifier(), - AppColumnChangeDate.identifier(), - AppColumnResourceOwner.identifier(), - AppColumnState.identifier(), - AppColumnSequence.identifier(), +func prepareAppQuery(ctx context.Context, db prepareDatabase, activeOnly bool) (sq.SelectBuilder, func(*sql.Row) (*App, error)) { + query := sq.Select( + AppColumnID.identifier(), + AppColumnName.identifier(), + AppColumnProjectID.identifier(), + AppColumnCreationDate.identifier(), + AppColumnChangeDate.identifier(), + AppColumnResourceOwner.identifier(), + AppColumnState.identifier(), + AppColumnSequence.identifier(), - AppAPIConfigColumnAppID.identifier(), - AppAPIConfigColumnClientID.identifier(), - AppAPIConfigColumnAuthMethod.identifier(), + AppAPIConfigColumnAppID.identifier(), + AppAPIConfigColumnClientID.identifier(), + AppAPIConfigColumnAuthMethod.identifier(), - AppOIDCConfigColumnAppID.identifier(), - AppOIDCConfigColumnVersion.identifier(), - AppOIDCConfigColumnClientID.identifier(), - AppOIDCConfigColumnRedirectUris.identifier(), - AppOIDCConfigColumnResponseTypes.identifier(), - AppOIDCConfigColumnGrantTypes.identifier(), - AppOIDCConfigColumnApplicationType.identifier(), - AppOIDCConfigColumnAuthMethodType.identifier(), - AppOIDCConfigColumnPostLogoutRedirectUris.identifier(), - AppOIDCConfigColumnDevMode.identifier(), - AppOIDCConfigColumnAccessTokenType.identifier(), - AppOIDCConfigColumnAccessTokenRoleAssertion.identifier(), - AppOIDCConfigColumnIDTokenRoleAssertion.identifier(), - AppOIDCConfigColumnIDTokenUserinfoAssertion.identifier(), - AppOIDCConfigColumnClockSkew.identifier(), - AppOIDCConfigColumnAdditionalOrigins.identifier(), - AppOIDCConfigColumnSkipNativeAppSuccessPage.identifier(), + AppOIDCConfigColumnAppID.identifier(), + AppOIDCConfigColumnVersion.identifier(), + AppOIDCConfigColumnClientID.identifier(), + AppOIDCConfigColumnRedirectUris.identifier(), + AppOIDCConfigColumnResponseTypes.identifier(), + AppOIDCConfigColumnGrantTypes.identifier(), + AppOIDCConfigColumnApplicationType.identifier(), + AppOIDCConfigColumnAuthMethodType.identifier(), + AppOIDCConfigColumnPostLogoutRedirectUris.identifier(), + AppOIDCConfigColumnDevMode.identifier(), + AppOIDCConfigColumnAccessTokenType.identifier(), + AppOIDCConfigColumnAccessTokenRoleAssertion.identifier(), + AppOIDCConfigColumnIDTokenRoleAssertion.identifier(), + AppOIDCConfigColumnIDTokenUserinfoAssertion.identifier(), + AppOIDCConfigColumnClockSkew.identifier(), + AppOIDCConfigColumnAdditionalOrigins.identifier(), + AppOIDCConfigColumnSkipNativeAppSuccessPage.identifier(), - AppSAMLConfigColumnAppID.identifier(), - AppSAMLConfigColumnEntityID.identifier(), - AppSAMLConfigColumnMetadata.identifier(), - AppSAMLConfigColumnMetadataURL.identifier(), - ).From(appsTable.identifier()). + AppSAMLConfigColumnAppID.identifier(), + AppSAMLConfigColumnEntityID.identifier(), + AppSAMLConfigColumnMetadata.identifier(), + AppSAMLConfigColumnMetadataURL.identifier(), + ).From(appsTable.identifier()). + PlaceholderFormat(sq.Dollar) + + if activeOnly { + return query. + LeftJoin(join(AppAPIConfigColumnAppID, AppColumnID)). + LeftJoin(join(AppOIDCConfigColumnAppID, AppColumnID)). + LeftJoin(join(AppSAMLConfigColumnAppID, AppColumnID)). + LeftJoin(join(ProjectColumnID, AppColumnProjectID)). + LeftJoin(join(OrgColumnID, AppColumnResourceOwner) + db.Timetravel(call.Took(ctx))), + scanApp + } + return query. LeftJoin(join(AppAPIConfigColumnAppID, AppColumnID)). LeftJoin(join(AppOIDCConfigColumnAppID, AppColumnID)). - LeftJoin(join(AppSAMLConfigColumnAppID, AppColumnID) + db.Timetravel(call.Took(ctx))). - PlaceholderFormat(sq.Dollar), func(row *sql.Row) (*App, error) { - app := new(App) + LeftJoin(join(AppSAMLConfigColumnAppID, AppColumnID) + db.Timetravel(call.Took(ctx))), + scanApp +} - var ( - apiConfig = sqlAPIConfig{} - oidcConfig = sqlOIDCConfig{} - samlConfig = sqlSAMLConfig{} - ) +func scanApp(row *sql.Row) (*App, error) { + app := new(App) - err := row.Scan( - &app.ID, - &app.Name, - &app.ProjectID, - &app.CreationDate, - &app.ChangeDate, - &app.ResourceOwner, - &app.State, - &app.Sequence, + var ( + apiConfig = sqlAPIConfig{} + oidcConfig = sqlOIDCConfig{} + samlConfig = sqlSAMLConfig{} + ) - &apiConfig.appID, - &apiConfig.clientID, - &apiConfig.authMethod, + err := row.Scan( + &app.ID, + &app.Name, + &app.ProjectID, + &app.CreationDate, + &app.ChangeDate, + &app.ResourceOwner, + &app.State, + &app.Sequence, - &oidcConfig.appID, - &oidcConfig.version, - &oidcConfig.clientID, - &oidcConfig.redirectUris, - &oidcConfig.responseTypes, - &oidcConfig.grantTypes, - &oidcConfig.applicationType, - &oidcConfig.authMethodType, - &oidcConfig.postLogoutRedirectUris, - &oidcConfig.devMode, - &oidcConfig.accessTokenType, - &oidcConfig.accessTokenRoleAssertion, - &oidcConfig.iDTokenRoleAssertion, - &oidcConfig.iDTokenUserinfoAssertion, - &oidcConfig.clockSkew, - &oidcConfig.additionalOrigins, - &oidcConfig.skipNativeAppSuccessPage, + &apiConfig.appID, + &apiConfig.clientID, + &apiConfig.authMethod, - &samlConfig.appID, - &samlConfig.entityID, - &samlConfig.metadata, - &samlConfig.metadataURL, - ) + &oidcConfig.appID, + &oidcConfig.version, + &oidcConfig.clientID, + &oidcConfig.redirectUris, + &oidcConfig.responseTypes, + &oidcConfig.grantTypes, + &oidcConfig.applicationType, + &oidcConfig.authMethodType, + &oidcConfig.postLogoutRedirectUris, + &oidcConfig.devMode, + &oidcConfig.accessTokenType, + &oidcConfig.accessTokenRoleAssertion, + &oidcConfig.iDTokenRoleAssertion, + &oidcConfig.iDTokenUserinfoAssertion, + &oidcConfig.clockSkew, + &oidcConfig.additionalOrigins, + &oidcConfig.skipNativeAppSuccessPage, - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil, zerrors.ThrowNotFound(err, "QUERY-pCP8P", "Errors.App.NotExisting") - } - return nil, zerrors.ThrowInternal(err, "QUERY-4SJlx", "Errors.Internal") - } + &samlConfig.appID, + &samlConfig.entityID, + &samlConfig.metadata, + &samlConfig.metadataURL, + ) - apiConfig.set(app) - oidcConfig.set(app) - samlConfig.set(app) - - return app, nil + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, zerrors.ThrowNotFound(err, "QUERY-pCP8P", "Errors.App.NotExisting") } + return nil, zerrors.ThrowInternal(err, "QUERY-4SJlx", "Errors.Internal") + } + + apiConfig.set(app) + oidcConfig.set(app) + samlConfig.set(app) + + return app, nil } func prepareOIDCAppQuery() (sq.SelectBuilder, func(*sql.Row) (*App, error)) { @@ -690,6 +717,8 @@ func prepareSAMLAppQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuil AppSAMLConfigColumnMetadataURL.identifier(), ).From(appsTable.identifier()). Join(join(AppSAMLConfigColumnAppID, AppColumnID)). + Join(join(ProjectColumnID, AppColumnProjectID)). + Join(join(OrgColumnID, AppColumnResourceOwner)). PlaceholderFormat(sq.Dollar), func(row *sql.Row) (*App, error) { app := new(App) diff --git a/internal/query/app_test.go b/internal/query/app_test.go index 3ccec2e4c8..9a9c613868 100644 --- a/internal/query/app_test.go +++ b/internal/query/app_test.go @@ -1,6 +1,7 @@ package query import ( + "context" "database/sql" "database/sql/driver" "errors" @@ -9,13 +10,15 @@ import ( "testing" "time" + sq "github.com/Masterminds/squirrel" + "github.com/zitadel/zitadel/internal/database" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/zerrors" ) var ( - expectedAppQuery = regexp.QuoteMeta(`SELECT projections.apps7.id,` + + expectedAppQueryBase = `SELECT projections.apps7.id,` + ` projections.apps7.name,` + ` projections.apps7.project_id,` + ` projections.apps7.creation_date,` + @@ -53,8 +56,11 @@ var ( ` FROM projections.apps7` + ` LEFT JOIN projections.apps7_api_configs ON projections.apps7.id = projections.apps7_api_configs.app_id AND projections.apps7.instance_id = projections.apps7_api_configs.instance_id` + ` LEFT JOIN projections.apps7_oidc_configs ON projections.apps7.id = projections.apps7_oidc_configs.app_id AND projections.apps7.instance_id = projections.apps7_oidc_configs.instance_id` + - ` LEFT JOIN projections.apps7_saml_configs ON projections.apps7.id = projections.apps7_saml_configs.app_id AND projections.apps7.instance_id = projections.apps7_saml_configs.instance_id` + - ` AS OF SYSTEM TIME '-1 ms'`) + ` LEFT JOIN projections.apps7_saml_configs ON projections.apps7.id = projections.apps7_saml_configs.app_id AND projections.apps7.instance_id = projections.apps7_saml_configs.instance_id` + expectedAppQuery = regexp.QuoteMeta(expectedAppQueryBase) + expectedActiveAppQuery = regexp.QuoteMeta(expectedAppQueryBase + + ` LEFT JOIN projections.projects4 ON projections.apps7.project_id = projections.projects4.id AND projections.apps7.instance_id = projections.projects4.instance_id` + + ` LEFT JOIN projections.orgs1 ON projections.apps7.resource_owner = projections.orgs1.id AND projections.apps7.instance_id = projections.orgs1.instance_id`) expectedAppsQuery = regexp.QuoteMeta(`SELECT projections.apps7.id,` + ` projections.apps7.name,` + ` projections.apps7.project_id,` + @@ -1140,8 +1146,10 @@ func Test_AppPrepare(t *testing.T) { object interface{} }{ { - name: "prepareAppQuery no result", - prepare: prepareAppQuery, + name: "prepareAppQuery no result", + prepare: func(ctx context.Context, db prepareDatabase) (sq.SelectBuilder, func(*sql.Row) (*App, error)) { + return prepareAppQuery(ctx, db, false) + }, want: want{ sqlExpectations: mockQueriesScanErr( expectedAppQuery, @@ -1158,8 +1166,10 @@ func Test_AppPrepare(t *testing.T) { object: (*App)(nil), }, { - name: "prepareAppQuery found", - prepare: prepareAppQuery, + name: "prepareAppQuery found", + prepare: func(ctx context.Context, db prepareDatabase) (sq.SelectBuilder, func(*sql.Row) (*App, error)) { + return prepareAppQuery(ctx, db, false) + }, want: want{ sqlExpectations: mockQuery( expectedAppQuery, @@ -1215,8 +1225,10 @@ func Test_AppPrepare(t *testing.T) { }, }, { - name: "prepareAppQuery api app", - prepare: prepareAppQuery, + name: "prepareAppQuery api app", + prepare: func(ctx context.Context, db prepareDatabase) (sq.SelectBuilder, func(*sql.Row) (*App, error)) { + return prepareAppQuery(ctx, db, false) + }, want: want{ sqlExpectations: mockQueries( expectedAppQuery, @@ -1278,8 +1290,10 @@ func Test_AppPrepare(t *testing.T) { }, }, { - name: "prepareAppQuery oidc app", - prepare: prepareAppQuery, + name: "prepareAppQuery oidc app", + prepare: func(ctx context.Context, db prepareDatabase) (sq.SelectBuilder, func(*sql.Row) (*App, error)) { + return prepareAppQuery(ctx, db, false) + }, want: want{ sqlExpectations: mockQueries( expectedAppQuery, @@ -1355,9 +1369,93 @@ func Test_AppPrepare(t *testing.T) { SkipNativeAppSuccessPage: false, }, }, - }, { - name: "prepareAppQuery saml app", - prepare: prepareAppQuery, + }, + { + name: "prepareAppQuery oidc app active only", + prepare: func(ctx context.Context, db prepareDatabase) (sq.SelectBuilder, func(*sql.Row) (*App, error)) { + return prepareAppQuery(ctx, db, true) + }, + want: want{ + sqlExpectations: mockQueries( + expectedActiveAppQuery, + appCols, + [][]driver.Value{ + { + "app-id", + "app-name", + "project-id", + testNow, + testNow, + "ro", + domain.AppStateActive, + uint64(20211109), + // api config + nil, + nil, + nil, + // oidc config + "app-id", + domain.OIDCVersionV1, + "oidc-client-id", + database.TextArray[string]{"https://redirect.to/me"}, + database.NumberArray[domain.OIDCResponseType]{domain.OIDCResponseTypeIDTokenToken}, + database.NumberArray[domain.OIDCGrantType]{domain.OIDCGrantTypeImplicit}, + domain.OIDCApplicationTypeUserAgent, + domain.OIDCAuthMethodTypeNone, + database.TextArray[string]{"post.logout.ch"}, + true, + domain.OIDCTokenTypeJWT, + true, + true, + true, + 1 * time.Second, + database.TextArray[string]{"additional.origin"}, + false, + // saml config + nil, + nil, + nil, + nil, + }, + }, + ), + }, + object: &App{ + ID: "app-id", + CreationDate: testNow, + ChangeDate: testNow, + ResourceOwner: "ro", + State: domain.AppStateActive, + Sequence: 20211109, + Name: "app-name", + ProjectID: "project-id", + OIDCConfig: &OIDCApp{ + Version: domain.OIDCVersionV1, + ClientID: "oidc-client-id", + RedirectURIs: database.TextArray[string]{"https://redirect.to/me"}, + ResponseTypes: database.NumberArray[domain.OIDCResponseType]{domain.OIDCResponseTypeIDTokenToken}, + GrantTypes: database.NumberArray[domain.OIDCGrantType]{domain.OIDCGrantTypeImplicit}, + AppType: domain.OIDCApplicationTypeUserAgent, + AuthMethodType: domain.OIDCAuthMethodTypeNone, + PostLogoutRedirectURIs: database.TextArray[string]{"post.logout.ch"}, + IsDevMode: true, + AccessTokenType: domain.OIDCTokenTypeJWT, + AssertAccessTokenRole: true, + AssertIDTokenRole: true, + AssertIDTokenUserinfo: true, + ClockSkew: 1 * time.Second, + AdditionalOrigins: database.TextArray[string]{"additional.origin"}, + ComplianceProblems: nil, + AllowedOrigins: database.TextArray[string]{"https://redirect.to", "additional.origin"}, + SkipNativeAppSuccessPage: false, + }, + }, + }, + { + name: "prepareAppQuery saml app", + prepare: func(ctx context.Context, db prepareDatabase) (sq.SelectBuilder, func(*sql.Row) (*App, error)) { + return prepareAppQuery(ctx, db, false) + }, want: want{ sqlExpectations: mockQueries( expectedAppQuery, @@ -1420,8 +1518,10 @@ func Test_AppPrepare(t *testing.T) { }, }, { - name: "prepareAppQuery oidc app IsDevMode inactive", - prepare: prepareAppQuery, + name: "prepareAppQuery oidc app IsDevMode inactive", + prepare: func(ctx context.Context, db prepareDatabase) (sq.SelectBuilder, func(*sql.Row) (*App, error)) { + return prepareAppQuery(ctx, db, false) + }, want: want{ sqlExpectations: mockQueries( expectedAppQuery, @@ -1499,8 +1599,10 @@ func Test_AppPrepare(t *testing.T) { }, }, { - name: "prepareAppQuery oidc app AssertAccessTokenRole inactive", - prepare: prepareAppQuery, + name: "prepareAppQuery oidc app AssertAccessTokenRole inactive", + prepare: func(ctx context.Context, db prepareDatabase) (sq.SelectBuilder, func(*sql.Row) (*App, error)) { + return prepareAppQuery(ctx, db, false) + }, want: want{ sqlExpectations: mockQueries( expectedAppQuery, @@ -1578,8 +1680,10 @@ func Test_AppPrepare(t *testing.T) { }, }, { - name: "prepareAppQuery oidc app AssertIDTokenRole inactive", - prepare: prepareAppQuery, + name: "prepareAppQuery oidc app AssertIDTokenRole inactive", + prepare: func(ctx context.Context, db prepareDatabase) (sq.SelectBuilder, func(*sql.Row) (*App, error)) { + return prepareAppQuery(ctx, db, false) + }, want: want{ sqlExpectations: mockQueries( expectedAppQuery, @@ -1657,8 +1761,10 @@ func Test_AppPrepare(t *testing.T) { }, }, { - name: "prepareAppQuery oidc app AssertIDTokenUserinfo inactive", - prepare: prepareAppQuery, + name: "prepareAppQuery oidc app AssertIDTokenUserinfo inactive", + prepare: func(ctx context.Context, db prepareDatabase) (sq.SelectBuilder, func(*sql.Row) (*App, error)) { + return prepareAppQuery(ctx, db, false) + }, want: want{ sqlExpectations: mockQueries( expectedAppQuery, @@ -1736,8 +1842,10 @@ func Test_AppPrepare(t *testing.T) { }, }, { - name: "prepareAppQuery sql err", - prepare: prepareAppQuery, + name: "prepareAppQuery sql err", + prepare: func(ctx context.Context, db prepareDatabase) (sq.SelectBuilder, func(*sql.Row) (*App, error)) { + return prepareAppQuery(ctx, db, false) + }, want: want{ sqlExpectations: mockQueryErr( expectedAppQuery, diff --git a/internal/query/introspection.go b/internal/query/introspection.go index a7fdaab718..ee96bf576b 100644 --- a/internal/query/introspection.go +++ b/internal/query/introspection.go @@ -52,7 +52,7 @@ type IntrospectionClient struct { //go:embed introspection_client_by_id.sql var introspectionClientByIDQuery string -func (q *Queries) GetIntrospectionClientByID(ctx context.Context, clientID string, getKeys bool) (_ *IntrospectionClient, err error) { +func (q *Queries) ActiveIntrospectionClientByID(ctx context.Context, clientID string, getKeys bool) (_ *IntrospectionClient, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() diff --git a/internal/query/introspection_client_by_id.sql b/internal/query/introspection_client_by_id.sql index 5129d99a70..1cc6baf9ad 100644 --- a/internal/query/introspection_client_by_id.sql +++ b/internal/query/introspection_client_by_id.sql @@ -20,6 +20,7 @@ keys as ( ) select config.app_id, config.client_id, config.client_secret, config.app_type, apps.project_id, apps.resource_owner, p.project_role_assertion, keys.public_keys from config -join projections.apps7 apps on apps.id = config.app_id and apps.instance_id = config.instance_id -join projections.projects4 p on p.id = apps.project_id and p.instance_id = $1 +join projections.apps7 apps on apps.id = config.app_id and apps.instance_id = config.instance_id and apps.state = 1 +join projections.projects4 p on p.id = apps.project_id and p.instance_id = $1 and p.state = 1 +join projections.orgs1 o on o.id = p.resource_owner and o.instance_id = config.instance_id and o.org_state = 1 left join keys on keys.client_id = config.client_id; diff --git a/internal/query/introspection_test.go b/internal/query/introspection_test.go index 6535bd1639..4346842bf9 100644 --- a/internal/query/introspection_test.go +++ b/internal/query/introspection_test.go @@ -14,7 +14,7 @@ import ( "github.com/zitadel/zitadel/internal/database" ) -func TestQueries_GetIntrospectionClientByID(t *testing.T) { +func TestQueries_ActiveIntrospectionClientByID(t *testing.T) { pubkeys := database.Map[[]byte]{ "key1": {1, 2, 3}, "key2": {4, 5, 6}, @@ -96,7 +96,7 @@ func TestQueries_GetIntrospectionClientByID(t *testing.T) { }, } ctx := authz.NewMockContext("instanceID", "orgID", "userID") - got, err := q.GetIntrospectionClientByID(ctx, tt.args.clientID, tt.args.getKeys) + got, err := q.ActiveIntrospectionClientByID(ctx, tt.args.clientID, tt.args.getKeys) require.ErrorIs(t, err, tt.wantErr) assert.Equal(t, tt.want, got) }) diff --git a/internal/query/oidc_client.go b/internal/query/oidc_client.go index d4364248bb..89d74d4ff8 100644 --- a/internal/query/oidc_client.go +++ b/internal/query/oidc_client.go @@ -45,7 +45,7 @@ type OIDCClient struct { //go:embed oidc_client_by_id.sql var oidcClientQuery string -func (q *Queries) GetOIDCClientByID(ctx context.Context, clientID string, getKeys bool) (client *OIDCClient, err error) { +func (q *Queries) ActiveOIDCClientByID(ctx context.Context, clientID string, getKeys bool) (client *OIDCClient, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() diff --git a/internal/query/oidc_client_by_id.sql b/internal/query/oidc_client_by_id.sql index 3a0a0a0c95..ef471387b3 100644 --- a/internal/query/oidc_client_by_id.sql +++ b/internal/query/oidc_client_by_id.sql @@ -5,8 +5,9 @@ with client as ( c.access_token_type, c.access_token_role_assertion, c.id_token_role_assertion, c.id_token_userinfo_assertion, c.clock_skew, c.additional_origins, a.project_id, p.project_role_assertion from projections.apps7_oidc_configs c - join projections.apps7 a on a.id = c.app_id and a.instance_id = c.instance_id - join projections.projects4 p on p.id = a.project_id and p.instance_id = a.instance_id + join projections.apps7 a on a.id = c.app_id and a.instance_id = c.instance_id and a.state = 1 + join projections.projects4 p on p.id = a.project_id and p.instance_id = a.instance_id and p.state = 1 + join projections.orgs1 o on o.id = p.resource_owner and o.instance_id = c.instance_id and o.org_state = 1 where c.instance_id = $1 and c.client_id = $2 ), diff --git a/internal/query/oidc_client_test.go b/internal/query/oidc_client_test.go index 357c34f4e4..bb0890bff3 100644 --- a/internal/query/oidc_client_test.go +++ b/internal/query/oidc_client_test.go @@ -29,7 +29,7 @@ var ( testdataOidcClientNoSettings string ) -func TestQueries_GetOIDCClientByID(t *testing.T) { +func TestQueries_ActiveOIDCClientByID(t *testing.T) { expQuery := regexp.QuoteMeta(oidcClientQuery) cols := []string{"client"} pubkey := `-----BEGIN RSA PUBLIC KEY----- @@ -232,7 +232,7 @@ low2kyJov38V4Uk2I8kuXpLcnrpw5Tio2ooiUE27b0vHZqBKOei9Uo88qCrn3EKx }, } ctx := authz.NewMockContext("instanceID", "orgID", "loginClient") - got, err := q.GetOIDCClientByID(ctx, "clientID", true) + got, err := q.ActiveOIDCClientByID(ctx, "clientID", true) require.ErrorIs(t, err, tt.wantErr) assert.Equal(t, tt.want, got) }) From 4ac722d9341888ac3e66a30a475550f6a1600308 Mon Sep 17 00:00:00 2001 From: Stefan Benz <46600784+stebenz@users.noreply.github.com> Date: Tue, 17 Sep 2024 13:53:43 +0200 Subject: [PATCH 04/19] fix: use body for update user on user v2 API (#8635) Use body for update user endpoint on user v2 API. --- proto/zitadel/user/v2/user_service.proto | 1 + 1 file changed, 1 insertion(+) diff --git a/proto/zitadel/user/v2/user_service.proto b/proto/zitadel/user/v2/user_service.proto index 9b82bfe297..20d3b65b67 100644 --- a/proto/zitadel/user/v2/user_service.proto +++ b/proto/zitadel/user/v2/user_service.proto @@ -385,6 +385,7 @@ service UserService { rpc UpdateHumanUser(UpdateHumanUserRequest) returns (UpdateHumanUserResponse) { option (google.api.http) = { put: "/v2/users/human/{user_id}" + body: "*" }; option (zitadel.protoc_gen_zitadel.v2.options) = { From ca1914e235df8eb62189cec07eb0de2cdad29629 Mon Sep 17 00:00:00 2001 From: Livio Spring Date: Tue, 17 Sep 2024 14:18:29 +0200 Subject: [PATCH 05/19] fix: user grants deactivation (#8634) # Which Problems Are Solved ZITADEL's user grants deactivation mechanism did not work correctly. Deactivated user grants were still provided in token, which could lead to unauthorized access to applications and resources. Additionally, the management and auth API always returned the state as active or did not provide any information about the state. # How the Problems Are Solved - Correctly check the user grant state on active for tokens and user information (userinfo, introspection, saml attributes) - Map state in API and display in Console --- .../user-grants/user-grants.component.html | 17 ++++++++++++++++- .../user-grants/user-grants.component.ts | 10 +++++++++- .../src/app/pages/grants/grants.component.html | 1 + .../granted-project-detail.component.html | 11 ++++++++++- .../owned-project-detail.component.html | 2 +- .../auth-user-detail.component.html | 11 ++++++++++- .../user-detail/user-detail.component.html | 2 +- internal/api/grpc/auth/user_grant.go | 1 + internal/api/grpc/user/user_grant.go | 17 ++++++++++++++++- internal/api/oidc/client.go | 13 +++++++++---- internal/api/saml/storage.go | 5 +++++ .../auth/repository/eventsourcing/repository.go | 7 ++++++- internal/query/user_grant.go | 4 ++++ internal/query/userinfo_by_id.sql | 1 + proto/zitadel/auth.proto | 5 +++++ 15 files changed, 95 insertions(+), 12 deletions(-) diff --git a/console/src/app/modules/user-grants/user-grants.component.html b/console/src/app/modules/user-grants/user-grants.component.html index efca9f74ec..82de67c8bb 100644 --- a/console/src/app/modules/user-grants/user-grants.component.html +++ b/console/src/app/modules/user-grants/user-grants.component.html @@ -154,10 +154,25 @@ + + {{ 'PROJECT.GRANT.STATE' | translate }} + + + {{ 'USER.DATA.STATE' + grant.state | translate }} + + + + - + diff --git a/console/src/app/modules/policies/notification-sms-provider/dialog-add-sms-provider/dialog-add-sms-provider.component.ts b/console/src/app/modules/policies/notification-sms-provider/dialog-add-sms-provider/dialog-add-sms-provider.component.ts index e766f6c360..7f3e3f3b0d 100644 --- a/console/src/app/modules/policies/notification-sms-provider/dialog-add-sms-provider/dialog-add-sms-provider.component.ts +++ b/console/src/app/modules/policies/notification-sms-provider/dialog-add-sms-provider/dialog-add-sms-provider.component.ts @@ -41,7 +41,9 @@ export class DialogAddSMSProviderComponent { ) { this.twilioForm = this.fb.group({ sid: ['', [requiredValidator]], - senderNumber: ['', [requiredValidator]], + senderNumber: [''], + // NB: not required if not using verification service + verifyServiceSid: [''], }); this.smsProviders = data.smsProviders; @@ -62,12 +64,14 @@ export class DialogAddSMSProviderComponent { this.req.setId(this.twilioProvider.id); this.req.setSid(this.sid?.value); this.req.setSenderNumber(this.senderNumber?.value); + this.req.setVerifyServiceSid(this.verifyServiceSid?.value ?? ''); this.dialogRef.close(this.req); } else { this.req = new AddSMSProviderTwilioRequest(); this.req.setSid(this.sid?.value); this.req.setToken(this.token?.value); this.req.setSenderNumber(this.senderNumber?.value); + this.req.setVerifyServiceSid(this.verifyServiceSid?.value ?? ''); this.dialogRef.close(this.req); } } @@ -104,6 +108,10 @@ export class DialogAddSMSProviderComponent { return this.twilioForm.get('senderNumber'); } + public get verifyServiceSid(): AbstractControl | null { + return this.twilioForm.get('verifyServiceSid'); + } + public get token(): AbstractControl | null { return this.twilioForm.get('token'); } diff --git a/console/src/assets/i18n/bg.json b/console/src/assets/i18n/bg.json index 88cc186625..3dbab7b72c 100644 --- a/console/src/assets/i18n/bg.json +++ b/console/src/assets/i18n/bg.json @@ -1419,6 +1419,8 @@ "SID": "Сид", "TOKEN": "Токен", "SENDERNUMBER": "Номер на изпращача", + "VERIFYSERVICESID": "Идентификатор на услугата за проверка", + "VERIFYSERVICESID_DESCRIPTION": "Задаването на идентификатор на услугата за проверка позволява използването на услугата за проверка на Twilio вместо услугата за съобщения за проверка на телефонни номера и OTP SMS.", "ADDED": "Twilio добави успешно.", "UPDATED": "Twilio се актуализира успешно.", "REMOVED": "Twilio премахнат", diff --git a/console/src/assets/i18n/cs.json b/console/src/assets/i18n/cs.json index c49d2ce87a..92e6d02da5 100644 --- a/console/src/assets/i18n/cs.json +++ b/console/src/assets/i18n/cs.json @@ -1420,6 +1420,8 @@ "SID": "Sid", "TOKEN": "Token", "SENDERNUMBER": "Číslo odesílatele", + "VERIFYSERVICESID": "ID služby ověření", + "VERIFYSERVICESID_DESCRIPTION": "Nastavení ID služby ověření umožňuje použití služby Twilio Verify místo služby Zprávy pro ověření telefonních čísel a SMS OTP.", "ADDED": "Twilio bylo úspěšně přidáno.", "UPDATED": "Twilio bylo úspěšně aktualizováno.", "REMOVED": "Twilio bylo odebráno", diff --git a/console/src/assets/i18n/de.json b/console/src/assets/i18n/de.json index f0f01e4682..871cdbaae4 100644 --- a/console/src/assets/i18n/de.json +++ b/console/src/assets/i18n/de.json @@ -1420,6 +1420,8 @@ "SID": "SID", "TOKEN": "Token", "SENDERNUMBER": "Sender Number", + "VERIFYSERVICESID": "Verification Service Sid", + "VERIFYSERVICESID_DESCRIPTION": "Das Setzen einer Verification Service Sid ermöglicht die Verwendung des Twilio Verify-Dienstes anstelle des Nachrichtendienstes zur Überprüfung von Telefonnummern und OTP-SMS.", "ADDED": "Twilio erfolgreich hinzugefügt.", "UPDATED": "Twilio wurde erfolgreich aktualisiert.", "REMOVED": "Twilio entfernt", diff --git a/console/src/assets/i18n/en.json b/console/src/assets/i18n/en.json index 63b07aba72..023fb6e80e 100644 --- a/console/src/assets/i18n/en.json +++ b/console/src/assets/i18n/en.json @@ -1420,6 +1420,8 @@ "SID": "Sid", "TOKEN": "Token", "SENDERNUMBER": "Sender Number", + "VERIFYSERVICESID": "Verification Service Sid", + "VERIFYSERVICESID_DESCRIPTION": "Setting a Verification Service Sid, allows using the Twilio Verify Service instead of the Messages Service for verification of phone numbers and OTP SMS", "ADDED": "Twilio added successfully.", "UPDATED": "Twilio updated successfully.", "REMOVED": "Twilio removed", diff --git a/console/src/assets/i18n/es.json b/console/src/assets/i18n/es.json index 979541eb43..92f74c884e 100644 --- a/console/src/assets/i18n/es.json +++ b/console/src/assets/i18n/es.json @@ -1421,6 +1421,8 @@ "SID": "Sid", "TOKEN": "Token", "SENDERNUMBER": "Número de emisor", + "VERIFYSERVICESID": "SID del servicio de verificación", + "VERIFYSERVICESID_DESCRIPTION": "Establecer un SID del servicio de verificación permite usar el servicio Twilio Verify en lugar del servicio de mensajes para la verificación de números de teléfono y SMS OTP.", "ADDED": "Twilio añadido con éxito.", "UPDATED": "Twilio actualizado con éxito", "REMOVED": "Twilio eliminado", diff --git a/console/src/assets/i18n/fr.json b/console/src/assets/i18n/fr.json index e7f99670a7..65257fb261 100644 --- a/console/src/assets/i18n/fr.json +++ b/console/src/assets/i18n/fr.json @@ -1420,6 +1420,8 @@ "SID": "Sid", "TOKEN": "Jeton", "SENDERNUMBER": "Numéro d'expéditeur", + "VERIFYSERVICESID": "SID du service de vérification", + "VERIFYSERVICESID_DESCRIPTION": "Le réglage d'un SID du service de vérification permet d'utiliser le service Twilio Verify au lieu du service de messagerie pour la vérification des numéros de téléphone et des SMS OTP.", "ADDED": "Twilio a été ajouté avec succès.", "UPDATED": "Twilio a été mis à jour avec succès.", "REMOVED": "Twilio a été supprimé avec succès", diff --git a/console/src/assets/i18n/id.json b/console/src/assets/i18n/id.json index 2569835012..f4d76cf078 100644 --- a/console/src/assets/i18n/id.json +++ b/console/src/assets/i18n/id.json @@ -1294,6 +1294,8 @@ "SID": "Sid", "TOKEN": "Token", "SENDERNUMBER": "Nomor Pengirim", + "VERIFYSERVICESID": "ID Layanan Verifikasi", + "VERIFYSERVICESID_DESCRIPTION": "Menetapkan ID Layanan Verifikasi memungkinkan penggunaan Layanan Verifikasi Twilio alih-alih Layanan Pesan untuk verifikasi nomor telepon dan SMS OTP.", "ADDED": "Twilio menambahkan dengan sukses.", "UPDATED": "Twilio berhasil diperbarui.", "REMOVED": "Twilio dihapus", diff --git a/console/src/assets/i18n/it.json b/console/src/assets/i18n/it.json index cf793e78b6..8d81c8471f 100644 --- a/console/src/assets/i18n/it.json +++ b/console/src/assets/i18n/it.json @@ -1420,6 +1420,8 @@ "SID": "Sid", "TOKEN": "Token", "SENDERNUMBER": "Sender Number", + "VERIFYSERVICESID": "SID del servizio di verifica", + "VERIFYSERVICESID_DESCRIPTION": "Impostando un SID del servizio di verifica, è possibile utilizzare il servizio Twilio Verify invece del servizio messaggi per la verifica dei numeri di telefono e degli SMS OTP.", "ADDED": "Twilio aggiunto con successo.", "UPDATED": "Twilio aggiornato correttamente.", "REMOVED": "Twilio rimosso con successo.", diff --git a/console/src/assets/i18n/ja.json b/console/src/assets/i18n/ja.json index 4ba6a69d72..c617d82ecb 100644 --- a/console/src/assets/i18n/ja.json +++ b/console/src/assets/i18n/ja.json @@ -1420,6 +1420,8 @@ "SID": "Sid", "TOKEN": "トークン", "SENDERNUMBER": "送信者番号", + "VERIFYSERVICESID": "検証サービス SID", + "VERIFYSERVICESID_DESCRIPTION": "検証サービス SID を設定すると、電話番号と OTP SMS の検証にメッセージサービスの代わりに Twilio Verify サービスを使用できるようになります。", "ADDED": "Twilioは正常に追加されました。", "UPDATED": "Twilio が正常に更新されました。", "REMOVED": "Twilioが削除されました", diff --git a/console/src/assets/i18n/mk.json b/console/src/assets/i18n/mk.json index ecf038eacc..a975731e06 100644 --- a/console/src/assets/i18n/mk.json +++ b/console/src/assets/i18n/mk.json @@ -1421,6 +1421,8 @@ "SID": "Sid", "TOKEN": "Токен", "SENDERNUMBER": "Број на испраќач", + "VERIFYSERVICESID": "Идентификатор на услугата за проверка", + "VERIFYSERVICESID_DESCRIPTION": "Поставувањето на идентификатор на услугата за проверка овозможува користење на услугата за проверка на Twilio наместо услугата за пораки за проверка на телефонски броеви и OTP SMS.", "ADDED": "Twilio e успешно додаден.", "UPDATED": "Twilio се ажурираше успешно.", "REMOVED": "Twilio отстранет", diff --git a/console/src/assets/i18n/nl.json b/console/src/assets/i18n/nl.json index 525113e0b5..92592ba427 100644 --- a/console/src/assets/i18n/nl.json +++ b/console/src/assets/i18n/nl.json @@ -1419,6 +1419,8 @@ "SID": "Sid", "TOKEN": "Token", "SENDERNUMBER": "Verzender Nummer", + "VERIFYSERVICESID": "Verificatieservice-SID", + "VERIFYSERVICESID_DESCRIPTION": "Het instellen van een Verificatieservice-SID maakt het mogelijk om de Twilio Verify-service te gebruiken in plaats van de Berichten-service voor het verifiëren van telefoonnummers en OTP-SMS.", "ADDED": "Twilio succesvol toegevoegd.", "UPDATED": "Twilio succesvol bijgewerkt.", "REMOVED": "Twilio verwijderd", diff --git a/console/src/assets/i18n/pl.json b/console/src/assets/i18n/pl.json index a739bc08d2..9e8df33974 100644 --- a/console/src/assets/i18n/pl.json +++ b/console/src/assets/i18n/pl.json @@ -1419,6 +1419,8 @@ "SID": "Sid", "TOKEN": "Token", "SENDERNUMBER": "Numer nadawcy", + "VERIFYSERVICESID": "SID usługi weryfikacji", + "VERIFYSERVICESID_DESCRIPTION": "Ustawienie SID usługi weryfikacji umożliwia korzystanie z usługi Twilio Verify zamiast usługi wiadomości do weryfikacji numerów telefonów i SMS OTP.", "ADDED": "Twilio dodano pomyślnie.", "UPDATED": "Twilio zostało pomyślnie zaktualizowane.", "REMOVED": "Twilio usunięte", diff --git a/console/src/assets/i18n/pt.json b/console/src/assets/i18n/pt.json index 1bd825e7a1..18389e0fc5 100644 --- a/console/src/assets/i18n/pt.json +++ b/console/src/assets/i18n/pt.json @@ -1421,6 +1421,8 @@ "SID": "SID", "TOKEN": "Token", "SENDERNUMBER": "Número do remetente", + "VERIFYSERVICESID": "SID do serviço de verificação", + "VERIFYSERVICESID_DESCRIPTION": "Configurar um SID do serviço de verificação permite usar o serviço Twilio Verify em vez do serviço de mensagens para a verificação de números de telefone e SMS OTP.", "ADDED": "Twilio adicionado com sucesso.", "UPDATED": "Twilio atualizado com sucesso.", "REMOVED": "Twilio removido", diff --git a/console/src/assets/i18n/ru.json b/console/src/assets/i18n/ru.json index 292280af37..663a0d100a 100644 --- a/console/src/assets/i18n/ru.json +++ b/console/src/assets/i18n/ru.json @@ -1464,6 +1464,8 @@ "SID": "ID Безопасности", "TOKEN": "Токен", "SENDERNUMBER": "Номер отправителя", + "VERIFYSERVICESID": "Идентификатор службы проверки", + "VERIFYSERVICESID_DESCRIPTION": "Установка идентификатора службы проверки позволяет использовать службу проверки Twilio вместо службы сообщений для проверки телефонных номеров и SMS OTP.", "ADDED": "Twilio успешно добавлен.", "REMOVED": "Twilio удалён", "CHANGETOKEN": "Изменить токен", diff --git a/console/src/assets/i18n/sv.json b/console/src/assets/i18n/sv.json index 061cbb6bba..ab267adf14 100644 --- a/console/src/assets/i18n/sv.json +++ b/console/src/assets/i18n/sv.json @@ -1424,6 +1424,8 @@ "SID": "Sid", "TOKEN": "Token", "SENDERNUMBER": "Avsändarnummer", + "VERIFYSERVICESID": "Verifieringstjänst-SID", + "VERIFYSERVICESID_DESCRIPTION": "Inställning av en Verifieringstjänst-SID gör det möjligt att använda Twilio Verify-tjänsten istället för Meddelandetjänsten för verifiering av telefonnummer och OTP-SMS.", "ADDED": "Twilio tillagd framgångsrikt.", "UPDATED": "Twilio uppdaterad framgångsrikt.", "REMOVED": "Twilio borttagen", diff --git a/console/src/assets/i18n/zh.json b/console/src/assets/i18n/zh.json index 48f7ae849e..346f29745d 100644 --- a/console/src/assets/i18n/zh.json +++ b/console/src/assets/i18n/zh.json @@ -1420,6 +1420,8 @@ "SID": "Sid", "TOKEN": "令牌", "SENDERNUMBER": "发件人号码", + "VERIFYSERVICESID": "验证服务 SID", + "VERIFYSERVICESID_DESCRIPTION": "设置验证服务 SID,允许使用 Twilio 验证服务而不是消息服务来验证电话号码和 OTP SMS。", "ADDED": "Twilio 添加成功。", "UPDATED": "Twilio 更新成功。", "REMOVED": "Twilio 已删除", diff --git a/docs/docs/guides/manage/console/default-settings.mdx b/docs/docs/guides/manage/console/default-settings.mdx index 4936131029..dce9f4648b 100644 --- a/docs/docs/guides/manage/console/default-settings.mdx +++ b/docs/docs/guides/manage/console/default-settings.mdx @@ -118,9 +118,10 @@ In the SMTP providers table you can hover on a provider row to show buttons that ### SMS -No default provider is configured to send some SMS to your users. If you like to validate the phone numbers of your users make sure to add your twilio configuration by adding your Sid, Token and Sender Number. +No default provider is configured to send some SMS to your users. If you like to validate the phone numbers of your users make sure to add your twilio configuration by adding your Sid, Token and either a Sender Number or a Verification Service Sid. +Setting a Verification Service Sid allows using the Twilio Verify Service instead of the Messages Service for verification. -Twilio +Twilio ## Login Behavior and Access diff --git a/docs/static/img/guides/console/twilio.png b/docs/static/img/guides/console/twilio.png index eb253128a4007b285227a78aac6e84ea4b62c5b8..e8fb891d97c56ea743ff09a9aba1b62460f28c37 100644 GIT binary patch literal 35695 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 literal 40165 zcmd43bySq^7d<*A3JL-O(h3SnhlHf!5R%f}-QA@kB?2Er8V2bYh8nu18|m(@p&5o4 z?!))4yVm{VcmKbvam^CmdGkEy?6dbi6Z%bA#IIe)RJjOc{NMdn_XxF9ZGi75gb{ zZBNv}<(DqFonO2s>UoLZKVJ|%?Tjsl$5N0#G_PSF?(Wn$3mexP5+7~PW(KF78G&MMxrIyDy{|fA@D)^`IKE0idy&G>rPQ#hM}I8zL61V+Gk#U%dS3l0nhUS9bOJBC3AFFJkA^pwLDH0 z-m==-CLt;QT|i(Ww2dx7R!>(s6_$_^3(FX^a2ciXGl))2t;`zvhT#-pV-R7Js60V5 zHmYlBaYhOBE;n9o$_Z*)(|MhhkOjN1)w^R&=m`A}PTX^pl_sg&d~{@OWHVC`;qRYY z*xC+scL^r@IQ3@Hd>8w$bXmP<_yPa4f(pN~J_A`(b86C!IX{=srOr~y@)qaiRg)oo zb@%K-Ln9u}vsG~#k)Hm`m~o(@(sc$!`2aiyWvlyUw>6|$S+Yn84gXS(Ar>6?Wz#fz zA3ubV5D9I=?cGxNUF|%4B_(T98!ii~t2ZCB_J;mQA$EPix;@O^sLG^r`)ku|pXoR1 zcrw0=hH$tzT~r~Ijxad*eqiwZ-8pxUdF)CTIw9nzcssigvTuGsTfKSNF(ri>yrN0_ zu9x6@Y?-E;yrHe_1Hva3Um7C|zxS+qRn>wQo95f=dbH>pW;oS4$>=CgzKRuF-15#& z)d|*h+8s4$RYvT|tfNDf>Tfo^ze!9K%K!QwJ9t@sxU9X#>YJPMDckKC^sMwa%4YVn zmT0ihvPY&b4Uwh#_D)V7crBmu!gu4KLBZHf`@PeXIgal;D<;?7cnY7g)@q2r6A{$j ze#aTr0)qL2uBlq}UKeG7v}{;rzr z!qmxH#?r*Z!r@P2WF=#m22@2$>tJiZuVv8z=CgB)UBR91d{}>Ovxe6%Pld^)R&e7beXxY)-dygt5Qe5to$orrSbo z3cHKc7d9wnj8@g~4dA|D92_1mgu|-;FX6UMGG~6;?I?1O z(L7lHa_1F&n!)EZe$Oo%#e-E?gNSnIOD3+jB5duWqnMl`p`%*S2E^yrAsZ?FeilY_ zj?Ru=r<;Ccr(~~b-JAOdv zyuFixX(x_Tul=&&HYO}SyD{c+a&m7e`UaQZV93I}yw7cH5$Y`3nHP7mD=QoGs;g_V zBSNUCcVRx4A9$05Iru$}J)|`~v(z$N9g0)<_}aS$=XzRpUuVe{r-BE{@z{(3asTYX z#B|;Fu&uIYuSy|B;CiBh^~lH^Il0poMkIo<5mcrjA!LftdNSB>r>hoCV}{GFU&tSq zxVT}Fqm<|RwV=K}I^?%B$&sJ@wTk_tKvpDTv8pD5`DcTqd+|P z!xXPW7EZj!O=vq+{3|ry)HKGv5uK(Za{RlX;PMLR7a2o4$x}U}bSA>tMo(5^RZ;A5 zl5-w;jYKzd{y>h|m|a^ztHV%fMa+dg{hIP1E2Hg7D}N z4{S_I8uu?ZN{u(^YKJ)T*~5Ex0s{Qcj;3pJbrn=pbP{7yZqDhQzyu@fPf?FEeo#0b z;Xitmn1C-=_FoT9p`I<};2LyG_InD#)!+YGaL@O*zQfhpZSw*1OANYk!Kqr2FikMi zJ0UVD1S}Lj9-bHJ>Jdy2+!Zv^Ev5@6v23TsJu%578aMEZynk>-ShGJfn|+i{Nln@Q8lNmOlORJd>Idbx-VTx31wP}n!+{3NM!J*|CI9H zC2@~%&i;FIlydRgsKE+EsWBT1$AO4sP%wuxo$_$0m$$uR&T!}c6SG`)9@`y~Y-x6a zKqh;I$b+O`Jvbs5mkR~G>s^>{a8ZRG?MT^gEuk{kj>S0R`>t z?S+N%=i6N1Daw+xXxWz|Ym*YvnV#aBUUAVeRcVx&jS|t-_#bJRGX}3L=WMrkb9%jq zBM65-Zsgfu4M!n!f|-$d8%mq3sVL7~CM;{xxZPB-mCHSR?~Qb}wF{KCmHpmKt*GZ7 z)&yjrYR;$wM@|U|3Fyk+_SHlN`WoHB9&e=wvx8=iTFswfdWz!?G*-CxzNWO zJ2Z(?FP`GM1kMTzgQ}{8cQkmC75foR?DAOq>x-g4ogLHBZASG6=RLu{6q4U1O-)X6 zpI3vcF?jxn8DUk#%F)`uyQZzGTWNkB1+XBI;!bkz#5=`4^+Gd~HJFT?ebS84M;#iz zrumMRzP7o!vEC8r^Cufb1YtZ_W&1(%ytdC$HB(O8nn`PBAFZh;re>C`&au`#DsrEF zb(bq$!$EfObJ%XLny{ctq$X+Dd+(&IjF;5M8zmJI(z3`uH61=2qJ^MwZp$T4)86>P zvqQ|p!g7ESN!#k)lWWN@+vh=J#<(X#LN_5#c0Am1Y+I z`Rr^`Hg}7hH$~S!qzwu=o7W(RyDA=rN{6vD|;l#mPZjunS*jLh0zz(`B+S_u58B%MrG_ zPMD_l6Xs~r{!|}(*MKNvJp_-K|NOt9GzOwjlz@AEPj{F3P=09W_mHQ!SxR|J2UQH? zcG(W-Up@D+dnwlqht1Idh-vc{O_zGk?jvv_lldJFcS3S3-X^YOzLet+?llWv;42uT!3tj>MMac#=fbTE;MSLZ=)e2Le>!NY^31jMOnwMOjbpg|q1SMEvU#C^(VJZ$@UU-zF6oE8M-NhXV1|u8wv^zX#rrs2=T> z77%1g4!KYAc%O?iTa|HrJ#kTn8;_W@biDW}nFIy+OJUDtt?iX=$2ju8wYy+VER0(!;Zl zA3N*oKZdpr&z=OW-DJgI@f#27kE@YLK_1qyke}h?DLiVN6sCXOd3}vNPhzB*_Cn4kBqiC+6?gZJ9A~YkdYp#_OR$h` zFE3-x6;juaJfSX^&>w6+Vq#Ren5(CzG(@jfT52jo{;m~+73ROc*T^0AaHF#jA+xZw zlgmtXun91=iU%f;k*`3Tkc^U=LV%f{|M}~G1EZss$NJCb^z1(2ExVFca!%N|xtP2r z>{>mnInUFgdkk&_>PyF->ek4=Fpp{%D(>trw4zuGT3`f)6i= zKZMcPxw<~?Z@E2YVl!Xe0eTKALlme0aiR@TPN!lYjfs=`P95CkLLzy%h?t&sw{$Tk zc(INThPeO#lzRvSlgR1OdKEIt(U|Ss-mfiG@p-JRJa5|>W_wxza(-r@d&AfA7Stk znp;pyn6gl>F3{4`7L2=YnOlV@I9!u~IYK>_OQv{*gNB-G3V=~tB2PaqvT zIdc5!Q|Q`SHc_a>RpP3wp+#rGuAu*CCKW&z;Na8h*Bh05ehwbp$)Gf#Wp?|81I1tY zV_VoyhHNz(cNF}u*5T|Tn;Qn~F^NU`wLWKK{rxBc5iEeR(9A&#EcWz5L8O9*i%#YB z6Belu?f;(e)%ofAF5_HZIQ8aAL#<-Y8)^L%_4LZ^`naN`T8&DQQ>Z0dQQ)aZie8zW ziOEAp=H@ULqK#KW&XIxwQ!-hkaCJ8FK_@r)za6Q*+;z~5K`J31pQH9s=u--!XB1p- z(a+z$+&>HDzFC@ z_1nPom9NKkHlbWra=pXCKhApe6A-!#E&qnCw6xs*w#kGZl)U(R%dLV}LP89*{Mz#l zFN6hIS+z6B-#ud3j=Mf!-vMzq3lY>#xo1Wg#USHqWSK05w z;N;O}jbJy|Q{1-3>x+cQfof0Z4BtATo7P85&Rt!>(^M6d&6&DpZ4|0C&zX#?G=om8 zMJ!-v*G5)O7Hp%g?!LL&eU!JkN@6_28yf<^y!Vm2e|Ts_$d5;ku8y%GZ2ZUH9i%0v?K$6Nx=dGQ9d{l^E{5Qkb+=!38_Tz6rbR7z@c z8VaGFw#mU(mF8uqRVUA$(TbrS67zu^9zOk)P5fplDL8SU2QK8?)jdcZFun11Qt;&u zV)wpmI-*~y>zLky7(kvV-7-krloz~Ds~rWnU$du9OpsodH5QYm1QNjY8ZbQ1y#3_^ z=R%qq*uj_0z+_xVQ15<_*3HJ*cwplh)<z9IP# z2lb@Q=x6OW8~4hlF5QA08%yA5{_n#tB4=p zt-_i4%YNt*+>!90;D(((=RHv*nS2U)mN9T&8TKkixcKIv8>7F)RbQ$=;zJhKf8^oD z3{z^{?EF}AUeUF=??w0R%lO&1{PKoQDeX-^0EhjD(JMSZV@izX-j^iu>&qqzy-%sP zoNI0~103*0WX4Rz6zR#r7T32_JfVT6fSAG5053PH6(gOYIM+X%P5-}x%1+setCEKG za2q*0oZo}oX*NQ>Yh}P!$XLt!eK^gJkn=TYn?Hs?qMeg6^1)3o=$xQpi!*oe9!OXU z{Cq>PGc58B46@*pDUv`QQ6;sQ&Y}{mnC>tKv%ejb`3TVEfH0EPPp>!R`T!%8C5;)v zQ%IJP<68;43pk2Yzr#h)u-4RgW&20o!io^s&Bn*VYvq+mIsG>AIgQBSsP!0{@&DNEq`Py&%*yMn?gt^xU4gc)}dT`^V`gF zi@usCr;L88aorMV>l&_Sj5K-%U`@9~}^9bkfa@B1-4=Vrdfhw)rKc-wo&fU5w(V!m`b z=pLR{ZoGeS58{4@GzoD{QiK{gmP;DXu}Jy z##kMeCndsd0CR~z7Y*m~%==aHSsK>CkY}3lLFv{Us-eeNoYYTQq@hQ~U>kS$y%J(`3d*De zy}q!dUDVnkCQdN4vs3EqFiZh8>;4_n&BM#vJv!;Q_tj*Uro(?bQ|Sm$6LxI<(pg;0 zg4a4$_X*qDB$5)Py)Mpu>xBVP4tkz9=4?*}?`V(3>9Dywxj2+njuNE^`8K#rpzFrQ zVijz>7cvwYjDpY_He+}LHiCYKEW@0%v;$e69*U(cw5`)9zgDI7Ilf0IE)fL%elJPt zrR^^D8*?RQxK%K^Z+O^tZe|j6L*Qn1C}e_^A9=0CTd1F>26x{R?!A0Q#AWTKGJ!+u z3j+REnL|THK!6s4iMa@n?lTL2=w6zPB~KFW`R~6|#(ZVn-ri2?PnHP_3+LtG+1%U( z#Vgm|VyeNovha#?+R>>LfR%%iX!AIRSGeJK{VzKFlc{zk{eS!VUOhZAzPj@9(6Q6> z98II|Ai-Vj!w)i*A$<`39Tew72lvS;KN6%nesG2g)AecO^@F>YgmUEM4<)I-a#icN zL-sLst>3Ff^k6z{m3h^eiBRrd#e=YM{h3P9cU*KMy(5{mIp*_UR{;3ojP#tn@I<=X z^r~|vCnYHleZ$a|FMax9A>9hfz06R>)X|#%*2$RFkta%=(e{YC8p`Ugqa-x&MHs~S(nyE(K8i<8gZ10 z)xk;y;u^bvT=YMf($Hq;PqnsvRX;RT1K0vdbFwgvLB%ve!xsz;OF){!A)wvc+493+ z80A4NFUkGxIR?mM6DQBZl3}~T{!e)8VbJ#a`g+O8oRLw!2oDS!Tg1a7BY-sWt+(-M zMA)LcLSHipy%Z2g?laS^cH+TIY@%R#bX8DPoSfN-24nlIK^#z1 z*&q-FAsc61+`Vx!Ez_xdd=gfMr`Qg`j%(^$5RTVV)3^o7+;sxJmZSasYZ;M^L=Wyp zij=*hV|gu1&&R}N(!bNm-Y_>gIY~@SQeimTtspXu+S+eSCy)a7d81ed0eqTi8yC@usAAmY2tm z>tO;h&r;4Yf4c9>bMjdB*MoiZZ23h~0@BjVj3LtA`%hL@jHRdmmkl_7U0vb1IU33_ zwrh1`B&zXTy{JM}b-X_UU({j$86ltotjnf`N^#A$S9?foNUbqLfqPf$#Mv+Dg!tXu zRw8sSvAb%V=Vsb^#mk84zjB=vrR?L z_pi*}tS59OatU{mG)$NKC%au<)=5iuH%=b3 z?7!!h7tCg0h+6y$$Wk=Ar;K8EBeJx?M+sSORvh4bGIrgeg=v5?~4qxHx3H8m~Fyi){aaUWzs z=BLuUNxSRYpaGf14Rkume+2^dhH5?L^bflevJqPZL{qDtY*)d?d38n2!4Olw_bFS&VSz&7b0HkS0zH+G~D8fZfNm7rG|mNt}j2!zVYH# zaF3tG_S(1RrCmYyF!A(Y+L@8r*=Y68{Pw6Fi2q(#;4A1`L9QJo%gGRSb0Oi`#(CdF zXu|gY;|2K45Qor5(gBHty7m-^!@YfdZa$5bNTfIBS71*p%bC>$x*8KmxcEX=3nc8* zqm%Rf=pq~2f=U~`TIX@>k9;deoQpn6b^rZFcEHcLhFh0w5%7&GG+xH`tj;xB+75j? ztH=43(frZKOwtF_=boY~47k4S@xrs?FndoS)E#YJQqmZoTIWb97Ow^DV~~7fVnBEF zbfeK4{k5{uou4DNf8 z{`Knzgc^L%Vpb;w)H`i$9B$rT3+rtQ>pcMO8Pg%6d&D{YHMP}Fo;u3Pp)oP5tEJtc z?hK-hxqtp>WT}*wmGSCVZ&{f4{I1loMdatdN-r#gl^#md(kf)vWtJs(eFNMJCeDl=UjT_qVceDSq~Iho(J zFgqIv);cV8tz`RIN?`~3GRn#_fCg|;c}D12RSOD6^OBOif_uYz2d>pRr8K|>Mx+WY zFYmsIeHJ+^udjdox21!UA~CpeeYvb<&FPdzsxU9l{MXV(+R3*QJ`q`CM8F^56+DNW#CtcSil_$ilpf&f##0^OmHja{QS_|hLXI7 zlCI$vTj)iOQa@P839>L)!t(Cw2dJUp2XakM5Wdjg2{Gn|hGuqVORJ0h>qnr<)2Cw_ zuyu7|SU){kJk!;fURQ${)lo5U!d)jS6hu^?m9^Ei)Y1m67!D0@L7v@bQs7vk1-WQ3CHOFdb8T$QU4Ns4`q-3s2zA>XxP`icu7IIUYoxnvZEYH^?Ck%sh$;}Mhid4k&p+Rei+t8c9sK$A1H8vhu8vDbo?~jU zfZxj4?mRaIh$y4yV$~^XBZwO4+;OlAB5b7`N>y;I(Ez&QU1FOV& zna7!w)NIl4?`Z`u3vXCRl}Cs#kACV?fy6e(KE_Q{@mUAUxG*VYDy z^vcS0Seo>T-*2}M#@NMiu1R(oA#a;wFdm*EZFHf~nL*C>M~`R!a)^0o{D(k{GN#&^ z`z1>$3z%gyKr^+;-@^w_kB<`*!gUHhW(+#K)1laoS65&ujyPg^Nfd<$KLO>{$nPx| zkyABQxwP>4KF#_U+F&ij3qVH-9&cB>Zj48FELT*N6Dz98W0T{h_|(n4y{BtxB(fcL z`r)I=*1-wzFy((N{&^!hP3zqD^7i)nn&PS+8vmj)azG#{WF^znl|W6bja{!s9|kT{ z<<{3<`P@qFSWT3A2|PJ-<5(L5`OicpA>a`@%zGi!WS~MKmE{Oy6#^b;=5}UmXDf7Q zRM?nV6sS-5#2WVIbt>~MaPI0IeMxh32{X2%*?EV1_u9ZqNhSR=RLO?A{c<;llt2>)bRDuGWUo^__dXR^Q$J1!~&W-#e{jNwY&;O(yPVD(E zMzuU8WfqTU2_Rhm3FrSpYHGb&i8#ahjJ{>RiJq&eW$o>osC2HHFQ9hjMiTOLT)qaV zSW6?GTEM9eiDU$ld{>v>w~O+U66ttRbXbwwmf43HHD%cdPR^)D;tr$<@lx#yU+E(- zGj(FwIvHy7V}jYklas8`1RO^ngD8DHBAuHQEF}3bC%?OJoQ}fM>03YvJEay9h|0cw z=|jpIk8YX!c=A||tzVwNZnEjO`TA2}VM;H}4`N@4iB;t)0svvcf5WVAj>F2p&TMF& zT{&L`m_!mZJ!{?+KO!a$m1VoOx+pwId_JV7&HP>lnz@VGBncBI_EZE8HY+W=5-(zI zPgg57x4$zJld-X+(3i2#f&=fDPTFvD6Z7#g(rxW-s=ikBy(;a_*9uo7CZp_IbkeYi z4XzE#SU%ZPWz^Hp`}|AI+SvA0Od|K4uK@wl%rbc;of8^w(+vA-&a*-DRMdESIiT;o zh%u)D7MZq1r`wml6E+n(BG03HE`s~M)2GMAj=^Aa6Y-tDegR3774G2d?B-O)Ua!M> z;2t`#uBoX7YR1brg7`$~-oBzpe((&mH9v)R4iEQD4-2!~Z!B?}OX3(u5hk_3Zp>P` zY?RftKpi=}Rb5#rsn*}Qi44FEeE*z-hsRJPZYTNMMlQ!%Nlq|4F3!8Z+yDx|lTwzn zs?TPrR+gKqYvNg0&3~~ZG&IRc`uzO4&(jaPnc5Z%d~_ZK_YmUV0}<2R&2-^q%Bnss zD<_vQzOl2@B>SAaqi>2_*ZElT6V%O(7i8O%6k#12_scz0@bVFcavZ_$c6q%2;*v9o z!uKwh$<^V|Q0cvgj$&zFFe7g%Ts1Lr>py;qthpxHp{a+vy4a784i1l9=t%*_`}5pC z6eyn~-POyAv`~7Y6;EqWnaVaZ1VR1JtO*(V~-uH%V7j7p?|3UWYnX>5$&E(Oms$ z>Gh|P-@X~~sWU3LtH*xO(ZPc_nA@2Tiqy}s7%+6G9pU`|IXC+;9F&x~hK8A$Fo2sn zmfPAvDMb(tJ38|x-hlLQ0+DcQ=VrujF)Bm9|0OGMU4IwE6vdv`w4aWXn~Ym4G71)@M- z#7GACAA$D*sUQr>*x7D>z2+o_g%r3`q+fEOHg={?uCR+}vfRQvxV?p!)ASwP+1bb8 zK=7759=pTidO}goK+heFQOx`7Tkfkq)cidT<}a3^#yx&WtSBR6-90%17!u_-s;e=h zS(RF?ShL-q9BYyZ`k!4;K3YS~7(E!u3G-ggw!Sv$2&yMz0xhl%#FW0mi(}Q4f&z6m z)u+O@H(P@0D_9kUQNB14fPwPeItT{X)A=Z-sDXT2xz?=L80J{J7ccLv-O;~?W(B%K zqgl0BT9uBPrk2*gKBdndUGwAHfW-hVJJFP10&5W?OecKv*#F9Io}x$> zoto~)4AgjaZB7odR*+TL`kQZm7wZJ*3W4P-whITWrBSQVeK|bhW#^)+tFf`M5`f?X zLmfeTrOp%sT0Y6kw%4@Jo*dpA5JmP$z3%N}HYqFwHNJP%iLX%qoy)iG+D?;s>iJ#W zDucPO0TBrnU?3BSae`Tz6c8VM)|9c{MhOS6wfF1Qc>I-2^%wn1_5fnwVP_u*@ihd5 zdA5I%J3YHXCE(gqeakb7DpUK+2ExOgiYqVN)?o=-3OY7r!N5czHs(NJqd7m}vTCOJ z)esn`4kkN9u{%lHY%m^iQik@>S4;I55xt#rk9RXJ35Oy`cI$*@V%aqJi_oHj@&{Mh zjHX7u!{PZ63>*~pV!S|XefE2P2mc!}jOb5J^_?o+6KIUOing}CkHt0vO&nYao)6!) zb8m+&##58%0l$i}qU!6X-@o7WJL&68@E=~&JAb|R%Go%BJ=1)C8W5-pFpkA{1zw3e zKT2*WZsQ2V+`Z!{HO>lLhVqjp$iL{*Rx42=PE4jXw+}aKCk1T%-+_7AX(>iiq9{;1LOBvi4 zBrNWIsqIIwQ>6qcQv&w5smjh+1#$-tafqb2P~lx9o>>V}zu}@8?%n)SA?ZvdH}4xb znuBw6vR&X$TnPH-C)hs*M*(r*v95BS;-tL|xzv#D_`!H{aCeI(?uzVE;M-QVUBZ)B zcz8pIwLtSHQh723rgF1?)LpUHkfsO9E=EwWqj(_`b;#LZ;vw<#o0A%e z-}lu6P1*ga1c9s@Q}<7C(P;1?gLpxK@*4turXoR?Wx8h=kx}hJcP#@nLHsH65)FO7 z!=l6%wV>2@#L>R|Hx~)o7P3lf2)|8w+B{ zf~0AUq@gO)$>>M8Uchdw=Zca->~f zZ@+dmDy^$hUs?P53Uvnr#P!i%^JSHb%k30k+8E_=%_HMtY*dDDyww^il`#9vN@)Ef zWAoV!{hr;U%SECF9~HsOSXUOr#=@wH$_*&a??AYXr<6DQ4CcU{Gnsky@F~Q=N^Y_1 z$&C;qAbUN;p6YvSC+WNiRya@k<;GuX#N)OwvR9ATpN2tA#mTfNe`F~+(+SksdxMPt zs~a)w2Rylw&Xym>x(HWg$QV&)=PifnWwABQG8l8wtBu**kk7t#&q3I7SD1~4|L@$I zbCoa4QjgHILekKru_6k+jonoma+>Bv2VQ#};X0oWM#)(tbUdu^`g&(BLEpVyQQsrX z#lxdD9VXUk_k9bXk#3QMMefXFf$pi0tqF-IH%_iHd){btb6LBC5g3P=QgX**j~_ZB z3(LEUTT3d++5=Au^{T&z6joFKTeZULr;i>zVx=gyIk;Uz>aaJ#tsrvQbhS-Mku*ME ziKt6YeDgDfxcq3F!2f7X0WVAjAnsJz3y8t%cG!@BN_)HtbG+)<>69YBdry6Y5 z^;V2bVVTa=Wlsv7@p3EXBOaaue=^8-KxSQ7Tz0P$1YU&bIM|Ey)JBY?E^yz0?lIL5 z%Sw2)l9jVpL>7jI*=q2}6n<`_VRz9TM!?j6E_r|^gnXI4S@#YD0$w{K3xACIcODRj z#uv)nnecv&QL@zZ6hHZud6yHkKO0~aIywCa^?PPr|KDibD(8rt3%AJ>HwgGSX}=#AtwIhGh;)i zRl~0cAEgoUu~8BnFZm?=2^4GUlC(G&GsRK$7BQhf>NP?~uS-$2Zsn@u@VW9w}2+yvrHsz`X zlAZhT@c3KU=uK>)OoO8pm}0sy2hRUnh2E#MhTUO5WB}I+?kJ1A^q)^f_uoU`c?N=J zf(qXp@LKe|wi=bcLQ0(uy)VugtH~f|gf}xGRdFZB$FO&CcBXH99eRSmC315)yC&gN z37EYp_gMKW*Sq`)1(trpQBh>b#(1Mg+iah>IxxSUpJizO&&JE&r3-0_Bpw5S@=vfO zF}wQO;IO%spHF=q_%E%_>V^PQrSG@+iBU2*xh@OPzkHEn?w=KXslFKps6w3ilMeKd|tTA4rXI<3Az2K)tTIBb5z-7!O@<68;}XYraf> zAqRqVW#+u{E07Jr&;NO3=h!;A7Wjr{Y}-YA<+eXP{(tT2i-^JTQ;6U9?A4KA5LMvk z|2V|+WjgR(KG3}X9epDDe|`G@^N9anLqeifzzf;fc(Sd3wM0S_DLCwV3-X0?VdoKK zd~!kq#8UG>$Ml58#OSE9OWv`=VHdF8a%t;(VMB}mHPK5d?4H@}@rSc$J!?E^a0Ojp zYoJ(~!R}U8)M6UoySWnTz_bU@1jOUmA}7B^eJ(Z|EzJ#iqO43@?PGA+Z4BmR zY;COAm_h1%y>LXv_$paJZf|2}j06k{XlY6#Sh6ZAcu!8kiRS~;G+DW>>qlN>R!H^K z=v6g!t~|!OXl)-#*d_xNz|rIOfh=&=*@d?y9@>k23%KKlL6W6VQG0oLB_$SyyrR>m z9l#u|Jtc{AadRae1oK9Mf|LaAalJb;4GmaMN={Pp#>-1$ACaQk9JGrxlk)4B#Op=A zBAot1W%#J8Yj~>>Fcft3hi+}h0g^t#;saCp*_FK>LE>tT5`24$vb{)%fwW}qvW*#- zYD`J^GF9FA;((mMPtRP0M9Qdpl$XodurR%5>hJANI`ekzsPdt&FEjqTcG)qtyQkK# z5U){ta5t~CK|VbHLl%olcd)wi`olHyPYoNk!#(o179_>9s0?U|H3~{9kP?^Ptz#u& zjKz*`e*$DcNSM?&2NVaewDkA0%ehAEmbw_iO#uZ4fHX~zW zdc8keTnV)$DYVc5tT%?+_ErIXcDU1FO_hf9gAi99vTxX#lj*7dDoAEqSAoxJrgWYKt#_tzkc zeF^Ct9PIBMDJ#x}lBtHiW-kIS3uxcZf*Ig_)7LK=8agCQ%8Vri*cum?-P7Ci5j(-7 zedX6rfrnlnPA**n!fNl*dZ&((zsLS_+63lGM0bTc=Pe7M{nH9U#e&a=v01})K{C+e+|W^FhB0BS6OtIGY;Rl$hbBn>@1d=W#i0zxyS>-| z$&cUe07m{|d;ZF{H9LG;&7pcmS9IrklwF^Cuu3aqwRn;#+>1_l(Bdr>zIIlPrD{_0q+KMB^?iccaO1StZR%&S{`m- zlGoSYo=(Lc+G&cDMD|?G`(F?K8@XJw$2R}=Km0ol6j~}FC!4-}(+^+$#J(06HY~z- zYbhuSBROkp_z@a4os(T3=m@QgUZh|99g81iDbdqsw1ur*TzDlVB)GVvS9DoKJP&u8 z78Y3bFIulMkQhAeNKc7hS63;u7Xs&H6tme+z8+Ywxjj62#Y(XX?n}}uQm+|yNYimk zo$8m`7L@_*-R(G5*$`)!Vq>Icj4F>+xtR+!n0$d<1&>7v$K!Apdg=IXt=G%FgV$LswaU{n9FPYMaU4^Ip{rbTl&0gj4MwnZWaW7pdj{paMFmyCk?K|5G3ap+4V-h+V8qJpzbAy=uJ4r-K z#%O9OhICi!SYYj{oWJ@5&j}6zGhJ6}eW~51hu}B=g(QQ4*je3vV91`TPxU-+2mlMg z=u&v@)+JT?*0$bGi6wOaaMQqBbo}>2 zphU*NSttG2JWI-@Czw3wBlb85$Hg(NK~x7^IfuXWp`-gjx-`uVsoFNQA%PTH+clgY zxGm#lZ)(Z^D(z3*%y&Zpk$I6Ds)qSE%Y)W;{L#%RD8An^48 zg6hrdx{-NyO5~(q_DfsTpB+4HcV_%X&8#z1ZQW3(=dmru6*SSVtV9Vgw?i-&+TZDA zC#?Jo(o_r@)FvsWn3_L-s{Z`(0LDz9dTPY}K3nY256=ZgEVtdc>m_7lk#2cGdj3?` zpdTStR$3Pm~%9=&B=^-glOs(p`R0&m|U+@14&&DZDvT}@)W z1!u3ZR0AeC;VRSR*o!d$_rwrtfcbE9a~hfD`~Wa3I#nT-Rr3iERWYy(fLQ`Flhs|( z_=3+HM~9cx^}B*z_8LV@u7Bb*pM23M7@yj5S*1PE8C~`RjU_EWX)`0$BgeoZ5}%M1 zQO(1+ZUmh1bybtVss(fi0KnzzR)ORPn-g*7j-E!$O!GYr*)~HpD1)@(AZmAWbMjyoMte%Nk3Q1m@VAq7sggj<)$i@gx;_I_-8Y>K6l#O7{d|F2 z-*bCndXh*{`{J3hBDrA0^u-#xUP(#GRD(0RsY#CgluQQdThI5%=Lb-7G~TYgR0kBR z(N8yz)gRqfbU%JRn)P1DM&f5DR_1uCKsRn?BPAsn3CkA=&-P{RbNi? z`E4yO^6t*wg;Yt-T&QaX-tE(>G{eZEZ#Vt3ovb@eX}n?+VGHV`5n!DFuy3v^F$6Hb z_NN7hKejSDl-WpQlZ9MSX0FXG0obelR6cthTN4%&>7U_0VID5bVQn@4!FbZ0pc?y) zbJ$@3HJ(wDhig^?RyVV(x9hK;qEQ`6Q-{rba|z1alcufOg>yetiiLDA9Ips;u1}&g zOl0|V|1%Y97Q6k9Dk3cUPU^t0RXORJnGcPAWlY~9|GO#uJ<8ygwPJ5??>!H92t?HB zoL1Y_)$#D;AXd81QwPqs?zwbIUE5(_U0z-fuQi|i1qkzHD{IOLs)~SmMAT;uMNQv@{gSAd>8V<8 zFF;BD7tc`{k+);Omww2Y>VL5|pzoJ5>;%&WmOaO&;qa1>#0A|QIA3oR$B|T*S2~C{$+D@S01R(_S z-`ubj81w$pXm4q$X?XBTvr!fdk(_%VG-6$j&o`6qGG`H(aoG6 z%O?LQd3AktGg~DeOkcwIUER*YHTGn6b(aU`&x`><3oM6VrpAV}79-sgc)YXBs4Ov$ z$ge0|JkiI#upGSXEH*GV$Kg$y^;z8t=~maC_Pe@hMCet&eEGU*d|H_yI!;yP6|JDI znqHR*Q1@><3*!hqQetXFJw?~N6)t5M47R+uMnp_aNJ5yHqU~VrFAgRzK^ra|c(|N0 zpF6_Dn?(QguwH0}zBwS(b1O>!>a>V#2&u_3wv$`DDnp9Tbf1zkX@1^2H^1goeqI#v zg;p@OBs9EXs8hi^t$h~;f=2GGp2VIdkUYEt_w_Ur^-zs!%1>-%gT zu9o7`;o?4W0Afy2?jib!lZ)u|_!K6XIyXEl0|o6-ZYKxOi31 zBXj?HeYH=0Jo4uQ&z&gNYY%jzDT!)JkG{{Tqdhx1&i|Y_#_DG+%?3jSuK3#d?i)zc zQ}T^H6Pg)ibg7fOa}>cNj!ow(fQ)K7khd>|gk~aAr8alADk@5KfU8a^uOPp0)PjeU zbd4y~V-MS6!)rbHD{#^9MUHa6WA#j?_(MQd1G_?t84?J2WbD!t$h~4UxGMbS>jfsO z?*SV(p`wRIaee(RaL-*_m;zJSfny2y;(?SDT`!-n5D1;IF&!6otzpVt;?Rlyv6}1( zzhjGok&@LznqL})gqml#$Ig7}kSgyO{BHEAyU*mbn|Rhh+u@_{BE$JWr=6S>UMnvV z0NJ(LlT1*cHY9h|%G%R23yek|QG=uuZY{?migfZWD?gvZ zD>#>h1M#P4dBe}e(UHQ>ysfBVQXG7hg9I=Z45SbvnD@46wZ$!{#1>-`d^2KAN8c z9Ik(CqXN*ZNW*652BT7<{dZau7R@vBPXB8?RPpc7wsm)kc?OyBmL@5!oZ1S}APCFy z->u;qo*m@&%^pRe?Ccc?W_c9k*;LlG75m7y0*IL`KB>`7jS1q^cU7& zbm*ySyH9_l(~5t_9!EeW;3LaM`#hZ$jY#daz6GgDfX&VAp{dUN2EI~C=eV<>D5Bav zK5iSXk%4e{czbCzx;&~v59N!DYa?igP7w}}!`A}A?>Al)4b zn=a`NNy$xjs^CUSK)R%bO-Of137e2^HX$9G?l_b0?|0sN-h1AA&%Nh!_QyX!)?RDP zwdR~-jAuOK8DNTIKT6~l6{$Rx4Ux=znXw=jcj?`*wP^z~fxvs+%f_-eJr5r;S9d8? z$@>x<{`kR(9t$rM*Zaf0cD?Q2&)zPI!Rh0MXfq154F!BI$4>c|ob!yDBKh`gw9 zaj>eI+Tg$}DDRJs$D_mB0pvd0t*y1E6WkStJtLYD?X*G^ZnM)Q>2K-dkjQREfTVU< zzRgo37mOqz!mssMd{!8%qdOHrCd}^{KvJmT#ltNuz|k|-SyxuJA*PcP9W8GE)?c=8 z)cBPKxTJqwEO?Oj{PR8@YUGaej`0kvU$KwgukF%kD6X2E$A72dsKKgmtGOYo6P7Bm zqyUTa(Z#lCQ1|HrI28E&Vg0YvV7A`~ZvprfGX3HPLHvJ$eZU|7OIQi~|Nks5<6u#H z2V!Jj?cMn2cYN!TLyaDf?zJB<(4H_yj*itZ4MC05-zr<EyhAsk)jy z1gta#SdVPjA))OrB|D4(ETr}WlST@58;A4Q~^Xx33^RPd_(SyHE_ zUa)s}89GeR;M|{h{xQyV2ClKN)1>m0IRX61(_Pii<=A! zKK^H6$S#*@Sqj{y$4kMTd_YyQ_3=*Gnt+TjK_ zIJT-%LuU+YtW+%~1kQ45?9tFkw8roY7u>SZ`sp8b_XGx!L3PM$=NkE#ok$<5LG@2* zY@xQ6CKc~&aEz?jaT8zY-%ge=C_8M5`YO}rZNxp|l@iH<0ik9xWNMO+`KguOFyYKM zSn-MSh2|Qy{v8MzDKco^4LLv%rOGT`DKjJSKxx2vsG!99Y0Wn}YTtvLi`CCkl=`a^ zk;GVcGoteyv1P;t^5d)Jj2HH3OYJKjLR8fnIu9d#=G4z$N1%v{cxJ+1G_YZ}hE}9L z-F0f4EFq@s>}KbY(zzE;@Ku8j`Aq=~Gn&D3kf8&-Euh44@Gp@g|J8qs9Q~&U)cn-A zQLRSXknJh6we~!+wt)HUgOtf(df>+iPW4culaJp~oumfxJwjIk}1KC00aHVy|y?#WC?a5Q10n@|eK{0*S|tV=QM~=opR{^z_{ton1Pn3!N4x>qYgh~$cMuF&>WRY@ve`Fieme4AN zNzr`>UnUh8h`dag$nnLI0Cgg>a(!IX*dO-bDqSI|{0m>$zJp!;kcsFr&ze|Q8>MKc z&0jK^p4`6YP<8*ZTmNJUE0K$QoBNN0mu3QB7WbM|@V&|y0^stuP{Nmm;(A62@;-hx zrLja@=R#G-m0BL#aV2@gztvb$&&S{S_6-vEW2V7*X&y=#2l+VsIBtLmSo=}J{$RB$ z(Pas`E4DxQ#6&3HwdnCSslYrC7q?tADSlw_csbZqApOV4N=@c5TPGgA878ftT+eTO zIZ>G|uQ8tL0W&j_<#)E-#x**vnA_Y>tE#usB{89;qI6liP{q`9hqMyLxfdCvF%C$g z^}ysk>cAlQGE$BIGva`llFElWL~!tU7xBbi-tUGFZXqdlRz)O=EUdb}QP4@zwcD^nMU1YN`|{&Zv~ZTP1QU%H4mK; zBrWExS)tqa{AF&E%xts(LOWce^-WFm-rlH7(z%>{tD~RsHiTrk|KS>x=J{v~27Hwk z@v7Q27a95doAaqROR699t8>w#fc{PK+d+MCc|o@WupV1cKf^wW{*;m0oHzTcWO8Hf zDYbE;&$e^qr;ewEa|<=(b{P0u`_+RI3-gG|>VjkIXmD^<;T?@x!Is7jp>?XD+0;#gk{ewFR2=#$y|~s6RvHQ;Q_% zKobg=_hYw%gxXS!x*&K&Y#)gr%?aiWL$%bB(J%1`m7`v(F~rhgeS*=fT)g|avD_Ft z@`w}vVX(~O2V*r%qG(vs%UJx@0wyu}CJY3~&29jt}RhR%0@4X7FvONQ1 zg73=KSVT>JL*_~eO4zCA3Ff{Nx_W7S#tXOp>gY4{U{&1h=|NZ1$)TaArvfpnq4#`L z;Pn-{bIMgCux6W6E~WpFQ+0#1i=QbnWW`r-+~dzi@nJPZ1nU&PokBNFLVP7c zv+p4+Lg>d~+&21(6Y;N&KZ8AkRP=0qK#JJY^>6#_ zfYq%0yAZyaY19QvXQ=QgJLkd*yn-<7l7IAD&x`LcBFL}2_VL{+Vgtb3U%GjUGHJ!A z(mKcM-Kw8QA*Me4a$1IU`oH7h_J0EHx~Hfe=VC0q_v` z6nuTJ$=8C0JJY<=D}Wl0UH$T)#J)x2#blkJt0v*Vy_H@7y2XIklKasdxy5VPfGAyV z*SzA)2qZ%G={sK4kD<=q4~^doPZ!|DbVgCTo%C{j@rxqmRc|{i^*}h@wWzsE8MP zmFRJ&t{4P-)z7X5@Z?2Khxa3k02hcLkhAc~lfRryPIJwXtZT*oCda`I%e@UCG)zaQ+vz2C>^U^A7B<2E*%RdKeXBl^)$gi&c>51}9>Qw}UJ3dBEeZpzD-0`TaJxZ}NMx(g#f;#=}PX8EcMqOJR>T=kNJMFYlp;>f+LS2}F z$0xhAY&z?(p@11Uy>i@iI_f3phQ=w3lBK;q-#>TV$~#+)?(Y|MyOKa@i$R}+9J2a( z4Vb%K?6)@+XxF9xG}0+mtnzKmaT7h^a>M{dOF?YMg;v~&2VCN84LEVBIZB+XAmK1M zB}l6hEW#~OsG>ebwz|2>lW_kphqi`B(7?F48(#;u2TL3xefKwWeo9|tnH(_@Q5*eV zz_nD2jn!^xxfrA=QTDw@xf`t`q}#QZXBi)af}RKX&*=Kho#-O_IPeBq#HISM z!+JYj`^zcHewE8stnyNMMD+zIP#9jK?=*SsB_v+DbjbuOU7uFDp^o8Gi?q~%dRGWT zk)L*#nj1VeiLr4$OG}LCn(H``G}uJczQ&CEA!q-!Dm~u#R`~V#VRSc1ds4B;@o6pV zR=C3o*F6Z|<*#H7Tb?9?G@A+?@rDQ}O(YyAXcz>ktsO6TTgtQwv)d&E^o?P#wAQtQ z^UId8N7@A@JR8U79~+V~XFMsJ6D5M?tzC^3R0LF&1 zFNAjV>RC=7z{$xaB?+3SYT}8P^72w>m$+BMubm!_88?+8%3NiBtsLzrm#q@MB7b=) zlGfyBv{OCHES zb{5p}gt~Ns+_mn-^XHF%ubv?rkA4Ic`ocPn>g&l$PNuYVzOl1owSU?NcqYC(LEOKOae39;^K{9hW&$kLMIjddG6l1dThz64 z^&ZbEN=s`;DIW}zy{GT#??QHgx*U#_-!M;w-a;ssFe~Q?7q6f=l~G;odEc*a^=EFr zPZ#*Eaz(w$tL;4<2Arw)&b%>jxo`F@C6U96MZc2`Ri$g6139fHZeh3lR|&4lR4g>B z0DUnn7WoPNZN*stk#7y8yAq%2N)|jrlPN%DYN5G9YxaES=NRzZwofNNA1@wbtgn+E zo|zU(lIyJQx-%Ycb3U5_OU}cxM6wVQ*$C-l18}{*a8B~3bKI1^+s|P5k;YiQ8hh|V zhnSHHy5V6#%7WeVJBXsDg?yx@-tG*RpZipWP=C|q?0VNLp-FCTN{!!U_lEZ2^!%?% zei-SfA4Y1o+PHS2)4Z20j%PqAtLs)#qM(I&6iX!B*o3==KOvIo8D#E6zB?HL2rooS z?Loue=2Q{AefxT1-pMn*f2U|GbZ7~JgP1x6i9D$A9yGY#4H?N9qO}^%$IpDA6{{c( zH_>*Toz`y2m5~Y7pAqhyjvKL(lZF$ANThF%oK*8gbZ)uUiKpbaCJB!R_BaIm!MgPI zb5nx3DWQ|KiF69h>y>dJ7Bvkc7%H)8hT7^IxF^By{q#Q$EyW_Uyp-4qE8U#0mvQ^` zJ=Ow4mU{)J%zr-XUEIAw!BQ_;t_z}MvdQ`{*NRzz?51T!;@o8ISrFj!Tm*Re9fVw; z&y=xV><>u!c9#}(Zd{I8!=x&0ApVQ^eO}r`PyTu}%y#5Fq5h)W@h#>t2R3OkAr&(- z_l(ghcLLKBgc`MsjEt**{<&4So^rYxIOYi&Wk@RP{$lwqUht=p^YGdWYA!lRE8CJ; zJ3(+(W+rmW6$l_ogl6RAXynTEAiFSeMAZ>79yR>x=SNevuGER5Fjpxlw^gg3^G>(n zH@l-s>WV;}=f%})G4BG6%!dYMM1-02tc(*i2oAoJ3Zt{~>gw^z%J9+3qfqCMV(F_} zTZF+96WUKPHb^npJc)i;ZiN%E$?9u!VNVJGX7Yx7Oqc((3e*8a);)k!?znP~{F_^>)uM z3k%6XX*~G6!|!VZ>{y{y-4rI^jY3G|4h{+zCY5gf_OHMn0>Nwe7s5m3`7ia~*9VEy z8$19-i^?KZLCRtpSFFz;Kf3E*7Cp8>%B7ocy{-0_lu=1R&g~ro2)YVL7gifn-1QCSK#M#-|ayVaF6W{kAj3Ppd33yGBfUuhpGPTJ6 z0;BZB|A$Z>4D~-_9skvk|0^s24zGqtVH2(~fHO$m96(Logkr>-`viVh#-LOL^pC&$ zr+06^$pXj%gybO@Asjxl;9eNL-N%Vv+mfZX@ZpeI4S^p3pm|-GhTtV&b`bB** z_^Bs_lQT5zPP2V73hg)vwxFyVy*@r!z9B1F$3bR9QJvB5uX!xM7Y9E_BbNx9c<2N< zt9zTjd-P7?7!=3Bv4g;TIyn~6oB1ZED5)gn%(sU_p^W~XJR`^I%E0+z#)bl9E^+Z`Y1(=dxL>faj4tkSaq(!XqgOl(=pXkbs7J=; zfUS)uDbw7~7EGqUG&7~yD_B}bIUB{3HD1LdR#-gSu3lLxT?Gk5uet;xrMb9F^9m-% zW@hA|#4Lh-M$Jr*F;$|XX$c&_4mhUkc5-6S!0sq0ED2=iU25BCL;7ih)*tcIpI_yUd7}sW zNA1`W&Z488Tf&YtMymCW(cl5Gjo(|HkoL&Yb zURH22$f~MDX%uUt?fHg=4p&%`McLkOZ?DbNx~yEBIvIHFlT%}ID?&s1b)}(c1qLgJ zo_l+av8=imyL4%|*opk%$bRIh)qW+pAmftb=~7(5@7^u}EmV9;0y{IW#bM&120|fI znYCd~5Baqs@6_HM_1gda+_C3YG`~?0B_ow&-0mH2$}{T%BCfv4Kd?%bz@uS?J78{n zY8d-8?{T$ZD_F`gc$oL<#i&AmKSI#`_poTQgE!EpcxPg5r#KqCO(n>g0tc3R+Rp8| zc`nY-N0^OG8XDtu>~pcUqJdz>dr)3pepWyG>7;u1*?<+dOl{jt%!p#{5E^%QgdBK4 z6B7w76?Z-^hxf5iI|`&+-uVb}F7PalSN;27p{#h6d8{^}eL`;lT?pI6uj#61U_unm z8VdT&ZXSzXaFxGhdLq+i9Mb+w$GBpgV^GQc6GFDIbc#H^Bv-h>rv-x}!C6wm(%U;( znx2L_Qj3YqZ%;agBM2VO&|#&@VDcft*4691hd&mmsiC%dZ^N^yS_FbXKvL){N?m@E zIkYA;090^*Sl=gkSsmJp#Kb#KErSv`yTR+nHuN3lHN|#vTpa7DH}dFOaMEWvUhf~R z(X*qj-A-f-&&-A$`}SCWZ!kb7R*;tKC|FrJ*1j<`&?vtOQ2?6ZbSiw{%#h^xuG)pT z#goD**X5O^PmSTno<1y9x|tfED+hk4#}YR6psY$5V%WvzBYlU4zx|y~W-8Gt82N!a zN{K0{p3Naio$y{|#He_E$9R|_#zjkW?v2kS znwx;?JxpCeCsD*1mVAj})lNMRwuq2VVu;>GpUzaH*9Y$Pz04h_`{ovh2U{pZUWX)u zV%R0IVQ;Fs!$?e=r%-Cna_jlg*q%_6$3{4R8N*)g5_l9-m1qtU6*)P~%YfImGta@q zDwWe19v(hCN)X$!nJnD`tG&4TVqs2Q6#;b9-~VbHNvUI8=}Gn2{bEK?GQQ+W8fMGQ zU6Ffob#tf9Z6r0aZkvS=szEJRN52GRAz*F(#RaGmN@4prl2^AFYn|pXzw{GCI~GxO zK-?2`eX60A=qT9Jg0VO~_w^sNJDBBxK#SnZ1x4GwxE$$Y)tY{7ppqV2&SL;s-vNThfoh`&oF@juam2IjQ4$|+SqVKrCSi9cQpY9s%?y{Uh4=K=G@$O zyxKst#_kOD&B*@uEIIh%f1URDUp*@O*xIG@v8#S&UZ@q??RvFxqo`3x#S6p#)!l#m z8Rfih5ke=?*Y&##q{TvBE1<|rmNwU-3EgnH2mI>RxPr?A+t_xghY)${cUd4S$BK{i%+mafGl02>fW>-Y0KVtkAVt6Mrd0)gxH>`<8$1aedOlP85xMi&c{Jh-b@ z)C|^sFx+fHomWp?i%o$FZ+Mj4b1jB9cr}gn$_#h}f%k@@Txt(Vc^b}Ja>dejFs_|c zJ+wM2WmhjTEF|)a-Q|^w+sr;2es2N&<&}aa%B8;*eA%sU6Em$p&0PA@ z`!a5Kx5R^A+bU*#&B73qC;r>FleS_?Ma97ilBuhM;r&OsoV;}VvFc-AK2e9STwM)r zfaRAqyWc8~qrk{gPxb26ZnrgTk#GB;aR`FvadLJaq@Fy^Q*c?EOxqVDgfCLCh>@l$ z)U_8;MBVZ6=agq74Y(I72zAzE(a-Qtw*zLjPnrxxJ+8&C-G*>bAaU;R?J*F7e$ z30hQSS5}kz>TGVB&)QYm^?Gst#UhCMWT4pMafY4)i+WepS_fs3 z)w{8jaWRiswCh(pHj3&uW_Cu|EE*2+@8ERT4xale-CN|yk zG&`;Md=Pz1s4S&fjUfe;p9iG}AMlaE*ATJ~*{~QHSoLqbQ_jJo5A*ZJaLMMHJJyKSY*_1^ihNwff4~>2T$Lz2tZ4WzTXKs%N|Z;4>iS*P z>~nGsO7RN5r#RbL>}Tkf>z4f@hNXYwdNj{Aa04#_f6B1jIFDuiH+owra%gRHbv3^r z-=S_M*EiG}_-MG=iVfrt|L7NwG11jq9V6C_Y?YOy*m<7fvL-|C667l#+XoA$+&qM4 zr;~dE-t5oK{aGCYrL?KsnKm1h`eNy9taON)>=?M~_N3R68S(XH_89!Vxy0J+`ekQ9 z3;I~}>T{FG?)VF#e!|GE;WRu-R)Sz}5kp1AWd{mr$5Iw|KDj=_lcnXt-p{&$V)N5Z z=60wODW)exPvjB#BK8+BEJ$+RuLKQRg557masmRiqrST_t@GcyY3`kRAY)UHyK=41 z0d?*-aol=e1Cqs#{ zo%HOpMx0Jw*PjpcQ=aS^ULPcZ{s7{tA^t>o1E|#Nl@zz>iv=eo0%iS~^u#+ZovIz+ zL8DUrPJ<0a<)J_%1gz+@sWiW-ks(mJ;GMj}8@_mT(#FdD>@c<42fWC?eA@$t*Sk_x ze%I%`dJ0hMF~8uOoP8Tea@beIBu0795ALIUS3b^9u>0xQDZ$WxzH-y@TXro-03y?) zTY-@ETtTa03WsG2~bZ!sC5(=l!gRnXYt9vwE}Mn<55oyaOE&Bq_U(C$CO9poA1Ntc zBPLG_ZT8cfI&|JF7A!SczRmxWs$&L$5cP(a3<>~tGj zn~AJ5=G$X9G~>^&Gx0ESaVbxgr?Hk+?91jBK{-)<-SSmh1+lOqIbr31*Zy$I6$K$r zIX{0mNh~cj3m$!ZYSrn+mZ8nG*iC@)BUIVD)N^JEU5_%h(Fbc-ub>i92jo9L2hsjy zN=hw)+1NduUEb6t0_;{wpR-dx)OkG7&xa3pTb3T~I)pswLljINpCzFSw5!-<;`qEY zJnTo{fNpT(X@M0%{%9qLwF)FTikgnT#4yJ8Ca9+?Dew!(a*&hjU%jS!F@xuw8kaN! z=8N&Ib5i4y_k9JJO`?BBW;l0aZ;}zE3vc$n&Y06tr>+PJ)+yxp7LOrDtfHKQ>lhJ0 znH)v=10e`@>C!Szk5rf)ySX`8-q3d?v!|o)ZAfQrG$hYj#Bl)C5q+$Qo}zqk_X83O zb1$p|BuXBIFK+R8x%6JuCvVQ3vw4rvx%|0tcr4=B{Hg$xeqweYPZ{A{dpCt;cUod! zhys0vV;Fcpe@_TCKFq<)n!99=kj(rNHd5~Wj*02FXx|Z_zJrKb0^0WVEfra*R?~QO z*SvlBEC(HqJ`K09if8CSp`MkHqSN7I$r`g}*w{e%O5&{8ZBT?b; z_84|Mz8&BnPT|&4noj4ff0+)V*iI&!VX)5t2up9C?BQ1c&u7$|o)7@e7? zd3#saBU3XoQwuXPD;t4yeS0BT#Y7=bU9Wi>RX#yvKXBwMt0w5;5v=2gd}V~V9EgYx zlZr+`NbvM&5SMuV*v7`XfK`3K)YdW|%gdK9PrjLH$L#&qn|^{2=HcNf&j!{|x{b~` z{^lLxJ?<-CcdU_>!(8dfhlhlKRLZlk{H$s=&elKEHe@AB=|MTaK6n?8O-)&2n%ts$ z_uF`DeLF!}nIT&C-t9LI$^m*Wt!)Gxxz(LQ*jZRybqs)X!Mm$f>redQF!W}k2(wb5 z21v7Lb9Rd%)*p>-5D@09tYF@~ognWyo8hs-lIW)saTEH^$>aA$LJDokH%9${6KM-l zEHnfqor~N_%%r@wpdi9yD)%V-QFszjJ*82b?SDiw#;Gg4%jqL&e(VPdsQlLX&9yZ- z#lbC-jYsObRa?oTu%mWj{of9HGcDr!!VN7okK}U#6q2f9V;}dXa<`)QE8Lv7riG7w z0RJ~S)=rw4@fCa|zukDCr0TvuXX5`2Rt$9d2eSO{@dD@IA=XOYbft-=msDIUe@6_E z!_`p!y>#EFyWYthHqqD#0LECJOpC@5&Xs*!$lRu~fyz&hjo+l1kY;UwNd5=HXrDc@ zir?n|z&d-+_rv)E9}6IJOk)EB*~$oNeTjd=nV@)m<@jGTD7%Q2P_p0Qz$Adre_{uL z-~SH;BJ(?RSeLUBw_sVa0k3S!Ue`;|-$Y*E_Hy_c3Ry7SDUbN5)iLF53I2)R{C~De z-`oiRZh!4+2(c(9_X!w%5wj2i|0SPaGPJ%(&<>+z&#ieJpMj?ZroYji5zRke_&;#3 z|Dp)O4OCeRuZ~KjfVwT{`YL4G! z%*xflxnasx{>^>7{K!_{OGNY+6h%4cB))l?Xg({8cjz-9c>FFn@h#TWAlB^QC`=oaZ?IV zfK{X=>bd<4fKzy^&R$s7r0Y-F93Ax)MTi)v0wB&Ecl}dxP(FYTL{3&YL8G1OXE*#u zxX!#h0=PxEBKEwU4uWo)j~+durKRnoXYEakJ)c4ekNg=Yo3t72*?X%~NY83Ek$L8Z zKHViB+TX9O_2RXi?I_&@-Z(Lfesa{egU_O(;!w*VwOF&y7~J3*u;So%@ih9zZ+s1W z4f^0S5^cL`?WNeAyL1EYCaQAaUp#~G=}o&6h~h{4oRPsnYU;^yk10{ZOE>$j?hqDv z1;wZ?({$TO!CLIIZ^@fIUorMU>y%cYmV0 zW1d0=H*^HzVm%5C|K=)Axpk5P11B_4i$0mWb(1 z9-rfqg^pOkYOmJI%hf=k|KDjrJKgMoAEFoTy<>)GdWIgqzb)q~s!_r8)o~!al9LUr1eWdH4Q!1 z=KMW6w`Hl%2FQ60OC4)k=iQGlBa(DOv6lNi{-%3LP|uHU0WN$&7XA1!88M=_Ti&fk z51{inFuiPzS)Y|H0G%=$qJK=f&4IUtRhamFZUFdqU_Z;XKPt4FtZ%6P@x8wMB;b5| z@BFf3^a!|gV2fX0-NdvVnz8`x!BT|F7ErJzO${BI$CyAt;0tE%XC8L|MA$Xij+D}_ zFzY&|P6!Q;53A^_*7d&@wzw6f{uv;UP>S`WAl52&&^FLj#XFdd3`i33SXVa zKqsW8#U(}0%+9(@xM|`IU|qYrTn7kH6Fj&Fyu#{>U+t_-doTOf0@dYWUbjv@DHX1RbCgy?z`iOY_3uM-z}TQo!PUpf8!Hk zYC+4Z@|Wq3FYB{@P_eSo(J`7Vhn9kA8f##`ZPMfuwaE8Tq@tpNIsVm0J22mpims2A zfPhn4@f3-t=l4pMlkY$})YCUrj3pv$1Q6+|F>x+!?ATdEVDA#&PE|;~c~WS%>vU6p7qrq3}w(DRyJ2;NXW`Ht(VPKeK#$K8Tw^n!j)LqG3QU|Q&Jipg81eK3S%Vkk|{ZJ^NRe;6_ z8%HUveuxLckL+(z8_)vTB9l|})aEIJM}fin`(Lh%_(^}us8C0*s;+W)|Y z#5?9&t%Ja;6Z0;J9Tejj2bUnPKfU_v_))}cmcOjZ21U50^NTgv^04I`-PhISy-Qhq z|09^gK$8lZ^|k9W45&X%3{APUEJ$$VIk%0ia$0=T_uGmAWTL93b0;>AMEOhCq!${9 z7^KS6G5q}60GX%BNKR&=7O)ZO`1Dr)MuVI%^ML_n zY-}v>9y`yUZSx^8x4CF(Wmw|)Q&_0G`}%08GfTqYfYSojHZWrvOoqQoH%GSwrK*h9 zK^4bA`3v$^5B>={O*V);bI0hT)J3H1mp)xSL#8JZ|73Un!E0@RXkbMHC!lm2f{bPF z)nvx6i#lKDsg|>#t6OEVPYKDs3J92QO*b4z#UMYnLV{RDwNS62AZhOBy`cH|St4IJ zvRhtBMg~N%=T=s%m)n@s9F?wrUd?%2Wh#j@fjD`_cSB)g6b3fdeto|FB}bo1*VZ=H zX%H^Uu^1TW=()GLXeRc?hG?=7KfH%4yca*S&9`POAW+9}yq%=1eAevawl%G;t@Sl6 zOU}l2W4!G6ceWZ>AWzBlbTz2ElB=5XnJ>jE zs@^m%?$>NUHw&VDhsP`uPt;ke&OyJcvq?k|-}Ur8vi>SEyUTt*QMN#>tAIdf8aQ9&ILm^`GDY0Q}$BmQVj> z$-(~$dk0_q|L=4Ygx4Iri&*3uyQTX0N)E4bo8a_SJgZ(qp&n_1r-Y{6*@@3;nLUBsY79R*0^MH7o$o#uGmA$tevg9Cqb?>>YQcs@me+%{@>#Px`sOkiT&Roy9Z!<#6@(9#F zF9y@GG(YHT4wsfJ8kDF7bbp>$VJF4cs&kx17wF5AKZYiS-yKGS8l}H6l;>(p;5!1p zDki>H{GH|2!H^ZVT@|VLZ7P9Xvd-NAX-$VHElIqh=eO)W^Pdz3DpP_cs<_n}0YOxQuwvYcqAiIll0)Ga2l;0`Ba^S@2h(o4@|= zzHKYKU+>lm&AV~v>C)xpq@rLOTZjduLPc9ch0kw$bAl5I zFp0bGYVR<7+V5MO9N(F~erCkL@lTaXGnC@8{ZjO5o|(1rq63!d-o4})7e{pcw`fWy z)E4Xx;m(#2i`ymAYHC~!0PlV)UIt}SR8WZR%5we8Ip~t|5a39=mN*c+lqbx5D|BwN zuICZke#ak`KFR@}!>spt!*7jy_AdwC4+p&dUeWLou4i}rb>!LFmp-!9OzMq>{h=Q z(WH3B4uMuZM}WSK7H8$t;949*IAEew>lJD|)t#!^9tWj~+`^(t&80wOJow?RSO8V^ z8%GrtH@66XGr2EVo37`;`W}r814#{xOlG{BGL;Gc=Rqr{CU&m-VPoDE9vf0=-V_h+ zu9GIFf^t?19Ea9qL-MkqXv}MW9Cah}v_WvUthVEGu`7?wlTy zp$F(9`d;~-UJ8KXT4o;0_?H9NP8&{LCZYc0qXdvg(qjR?*hNUi#lME;$CD(;Pfl3? zTn)e}Cb}5-9zKmm8DTrS&@lUOK&bLxb^eiVDJikW&dW)n5>{xNo(td=%lpFkGcNu8 ze!G7f3=6O4cIHPvQ~DTB9nAoG-k5>Q#Gcs-Mim(e7ecLDhJnHe)1hX@mOO=Ae`YDW zT}GxBFb3oq6ck5anLKWB;AUziPd!#0T<6J0x2U?=d#X@I_GBxrbsnj4vNQn54pRUQ zfou3;pFSIJ^&42)E6x`R2xYpLZJJwCO27r1TG`*4G9)g!12-ml2!M=b-MT9z@2n@< z@cLw0Rd6u$$Jt!dz{?c8cBl*VJP6^kdTX>AEiT zU?zsxip#2aY;1PGi~|R6K(4s{bSUk@NXLK&pLUI)$!~E)?0K!|TtRGHw7MRjex^!S z*5Ef9{Q?aWjzi=UNBE-r>v$ohm!Ot+K@0C}RNxnP3 z#gB!q571{A_YJScnhZ}mt)X1ZtmpG-m!ONGo2%>fPA4wG04c12E*5U+b(-+)u|8;Y zgMig99-qH(GJ*`G*R;ncM2h%$ruv%!R63y6Gij!!v|z*vLC*+MO~BNA_{;}7aFbcc zK;NjDyM3xeuW!pJ4wCawJoRdIn7+D$0rt4Q1mTZxrYF|#iP#*8a-<>gx3!LXPG_QW zO$@gQ-z^$`ZTo!n%=%tf8PCDxcQyRfx*w~DuYkMy&XPf;&tKf1Q=Muq#Q9+^=rJ0Y zOzbcdMg>Sf);L*aO7XLHJe@ytls^$1@(TzR_uT=vRo%e6Ku>beGdJY8( zp3%fB@7osIu~>Jh_3uICaa2!CF(CG%duJEM%I-O8p$|&5{vIl9y<(on=E=^ziXUu` zxG9x1T6f?hbF5(+R8JPF(Ob6Xd!c`?**(YkZxT4*M(GS3P%}-|Z_TxC0(<6DuER|{ z2U^kq5*-OCXUyjb`L%^k!ags4Q0fr!>DPMVomS#?=Z9y*kMq7QSZ4zZ+;dwDb6$vMQq=|f&*=rt$Gy|C>c6Q8&&Z&WknW-r^$7@UgfU)2?PFU+22;u{NP~K<2d+ImxgF;83(vI#!e}H8q|wK=H(YX(UA z@t7R+rK+E%o6^pbZ<8VY<7Fk}+7>6yqj+!*`@h--c08T?K->)z@UIennG8UTY_JtN zFi>CH8RC%Y-6WCHGA--O1zAiyKOyh}8)(k^>lVfIfh>e@`v%7iyUB;0CuF=gE(b z*pdx=kj*nCsq8m>R_4_O9KtJ`Z2abkg|=r8?FE4GD5M45`=iX2#P;-0iJqbo9y30=MqSn?`)G{U`X%Q`76 z=}mtAE2oVj(JI+$zn9Y0ef_?J_VqwFC3UXi!LEbD?eykh6x!8`iAT4TCGzEonb9v4 z0Z{8a(%72PtzhL+MC9Zg8Tpv_W6AgTV^IvhGnoR&15c8$u)d+1n!Q-|%?6m<^0EXr zVpX;;6o*G?Q6>06ZrCbDn&w&QIQ+M9MSHsyC`t!H-J3Ui(ooJ)_}DiTx)=dtVrBt) zr_%$MyH*vlFQY+@rmgK{jzC}K*ADt%i>Gd2JT)$^)xpH@0~NtTAb( zFgYZlEw8F2sM9+a4?Tq(c1u$9XIgnHwQbw>!Kh3We=qExNwHFrkdTp(^z<)QJahSp z;^kv%nYUkFHZNoPxUGKUaASJu5PkWiQy2t?uk?z?Rvv{re|$spM4L$?4;mE}Wm7Tv zfw-PjcgonLg0}r0dCH7IgjD)l`DsTsXLB<%tpX{1nZztP`#Sw|++mEg^~0{(Pj?r2 zUzmLaw2=`jbv?b!oj@RZO8G#VmY_i-aOJeJvhNQA=nSHfr}L=l5q4s*G?fujHpDqN zm^?)vcn=qr9uoIXeJ=0Zjs+gh(=gkQ%jw&*1|OeG&_C9)<;jDB%Z-C78@@jp)cC+Z zGD3p6h8Qt5&vJ2faSHI)DFl@|@b(PAFa}+p zB==n-2V;+=`pak{B1Jmta4egdNBWz+4McuXSGRnB zUpL+Zt*R*#u1cIVdaH84C5d5>qhIqupg2peKf;s@A zWHH#+BTomo;V>Rt@*jniBF|q(v9zol1I;W)GGe_!l<3!Q-^K?ffDdCIaCYD( zJ>Q2yRRVdiD}ZRJNL8ymv<;6_((o1Ma_gUrU;#}fZ$6RPh)s07G6O?~HY%9z3DpU{8qV4jaX0+}Zg(k5T5M3^Lm zl7Y^d5Vj}g1d27-4z~=O(O7;+Z~)|h%0F!_6&ahxy7ra1s6Z_yU+SWd zfkhUA?+e=?pz_PhVB`C7Y`|CeBZjE);M(c%cY#bH26rHEq3}ibzW!660RLl>q;Qn5 zfK2N@Z7vlFgGjEDNJt;J-wAkUqUCY5>Wz#zw7+QTl`n2!j$290X$3d-7km$lzjb{Us6VNF{t=k!>6;sZt; z$zj|EY5lW9F`79RByul&-H04rcB|nh#G*1fKHl`Nig{>Q#(|sB4Uw>mv*UqA(7Pa? zEm500L2%q7^PScw_ri#DKa*BOM>7&ba3zpTBKqv}U0? zy_ksVS+dRo>B^z^yfkvAZ z_mUZ{G{Sz=NYPmc5_p?2jGuMnp}qpQ5HfXqjaF7P1u{nx&rX>-j`f+Dgf3sQ(IiGu zS0`-5%Dn%E%&^4QXZmcsrf$%@j1Umx69Ff5c=+#Ys-M@*Z&FxV7LROZ8WHsce4H<~ zE^#$Ms93C5Vsp5%VbKfJZ_LO#x5l+61}3;YiG}(FQ>5&yt;;4rsldaB4lTLZt-zHA zIUYG0 zu=?O)KBY6{Nsl^%2`G)YzTTQS)g;u;z}V4^k$oWZ;7y=3)g~2H2o-{gI>Q#R-{G2i z>8;p*;DJ_3+BaWIwW~vwE9pLNE7XO4H;ehn`kYBjn(ABy{rMNkb5ldOw`gv^rBzMU zmoFMV4K>H3ettf68(n1+6ZaBx7`l}J|Ne_a@YMaqsjB>eDh9FNdEFcmXS<+<1;{Cc zf1aruv^5XPsLj`lP2RK@h~r{p_3~7B$@%>6hUkpv)q`|N8d(YatxF~Ja-pNJ92x3-)3PsfjS0hm)iBTg&?HS?sy7%|z z`+m-Q&U4QBocEmPd!Fa}H6IWk`e}hH=($l2h3aB2N8>a%JGsh z>F7vRRrSbaZRvDt+y@n8UVEp=;uzRS&t{)=s*}P zRf|4CGpLqUcGSw!K1YOw;lEU4$^7w!BBCd#(n0T*aeSfs)2V}&=!kV%QiG$Ls%opd zeX@m!oOwD7M%~bF2{O#d4x`wIBQVYd(7bST^TEV+4t;5TamS+Pt8Nk+^@7pO!;3ii zI0BHM2IM>8M@HdOsOSi4ce?Y0oyvhN;A&&IwtV&W zZU0b6_|#M7w9!XEJf*g7cCC*qn)W+dG&_~p350M@O+o01mkGDmt}$Nls?qslw>h;9 zc178&ys@E#V(0QC2QAjzGCU&Y{v_~XC<<72cT!tTP4Jm{$%}CMjQ*y5S=Z)*0qwu! zMFX7z-&t&XN6r(D==Zl#jV@+iiHDoDz4oHQ->DRXDIjU3>5CVTqKLutM~zL?kR}z4 z^@9!#rk+FgyahlPmw2Ug z7JfPIc5rQ+@Y410!Y~MH%`i8|h4{N}C3mjRh;QNbI*~r74uFm}JqJ+F5$j|uK*Duth z-Z`W<>3C%igFM~g29!*&B1jgNHK`X+5rs!GG7ZH9NAn=3Kregy6&3y0h?##ja)S(A znkT;*XS`A7H9e#;6zM@v>>pcrK9*~-=DX4;A}6g-K#gKdlaP#qFHcN_?ql!Wf5>s? zwoQ1;?|=pl%)k)E((XAIZ@hq~a_ryHGw=Ex7x_M?^x7XTws9vWr~&7yQP)CfkzP1S zSer2qnzxY8e-o#ri|=oKySU&aRwd1WNyd@^StLvA&1u%+79U$bjqO91o8FO!y@#(b zWdcGyb49#P25tt<33E<*+`)=I#u)_#t+B_J%Ia?YZ$|4kn}cuPNNM)5t=r!Pb%#(* z{}^llh>!ty3Fl7mf%)N#00W;))$u75sbOoQML`je2x8lp>D~L>Q*n2~&w&zCk#Y&^ z6&%L|5lYGEr+j{$$w`UNeED6r0Wx=f0B&UQhrW68uElg%8=)5G9ai17i)rhg<%t)+ z?6a;YSi1R}mKe{LT*7bf2L_XIBVq*V;T5ocNl(wBgVy&#d*clHl(Y+48T58>rl~L6E zDnYOjvuGD`b+BQ=e4ylO#6wMq%Om9Df0E{i(c@%a|8m|NDL5^T@gtogMRQ?-MI+8V z{G_CO$=C)0sgf6!RuU`k_~h;zQjI*-@o&gv^WXl4My;dki=A!>>famli9C2|sWWE1 zOpH#V=H=#A)e4Wi{3uqHv$gH-?7VbW_6IZ1fTFkj?pWpuspW{e&-BxxDEMH2dj@-B z;LVOc=Roz_i=8mWpaNU?ruO(JjS+d9&(06*SbAZukEgkQGEGN@dRI3DU- zpTUzcn|>KpTDqSa>Wksj{uF>bF#8PAMj(XuE4>*~)!J<2Cp~P~3NW=M9%Jr0r=7NT z&xsT81b}uK-_Ndmvb0WGb7x!lQ8%L0(?;xH3FS!z3PAIG-ViQ6=`r)OnNY;M%= z)6OZnDV88spMGVB)#C;MJWlb(Gj#>C{SU4?7ycpdXlSsHw;ZSQUrL4Vwu%Bwn2ts4 zZ*nb0<~I!t9A_1po^`fNt*K^^mq)LSYo@6;+2owpU8%ndM3iD?7{^a?!YH{^e1BzL zA*WNeM7MjhsHkY0PXokDu+bdYt{``{vX4B2GY7Fv5W)~e{)v8PeJblxT9<7mzlLSX z{f10nv8$Bevjxq!)VyjRczDFKnXXn+oo0obrq+Nu1g8#pT=Q$I?`dhK`~EQ^Po%Qy zvv2S>&sX(}l<_Gk^|?y59*xrVytY-P4-r;Aoa}mWDR~aAe%Xy(6(kd}H;mE@h581< zzH&wFw8x>BuKp-`aO`FbEuxTDG!yLD*)If28y~H$3B3D8cFf7n z)=Ez4u?H}bz-Ao4nXo)>a1F?+S2@e))6`MG|A_cz*D@kjaf$bC>AXNMm`wIhE_wg) zdu@f+Qz`O^o}|c zSpU!DokGqs>hXCfYl|0K3fR6r2~KWkU^j2b91)LDBx$9g0DjN6JD0(--QtI#Pb>SKun4xL5w_B+^fno*nxj;V`lW{C~47M z;5_09v}zDw3C;e%BVz?@0}Vgz>sNNdvIB*){*3>iQe0QZL!yd-8S9@?q;!M?G(MBS zHTYZa;H~-YxSpnw29RIh$id-eEGaK=yq%a-1DY9y7R2S;JWsJ9diAH$>9d_cL8f9h z0XCGVBl|Gt+WFb}*2$Hr7H@zEcVPj@)8zKEl7yj`-MrmD`_b|zJWtjzbt2{*0WJ^f z)dN-+1C<%Ik|sQ@tt{HYk=?f9?8*sh-wq<+Whni7*YdRmaXn)IC16e!u(HnkBtY^x z7{Q{VZmq4bBDrvQO*0BXML}vF-FiDXh#>bZh=p6Pi^@sK^UX-+JcKSuzK!yrj^`kS z1S8`c{cs;Gp<(k!(ghfJe(nk-ENo$*HyXX!CZ|LsAiw|2)hx{Y>HjHhM0}fhrFeGq z%VwI>1eLW__<^p8sWCv0oXi$C3P=oaadFL0zJ)2CHg|kF3LbwbDG${}@BW$)LwKw+ z^2T-FjlIxyh<8?mncIvjE-%l8F}FwHf?KP$jGRz*Q{qw*LB+e(`-1N8oWTgf@!Y;C zJ%d{GZBD^>ZEbDSBH@BUWyoLMog;_kbh+>WT4*B{%rpIp9`x*ZQ%12tO=U3u0U1 z+k+UvXcXEcoBktji-qmOce~O9+80x2$pIEL^dApJDjY%-*)ClJ(+&}0qBm9%#3=1X z4ymS^Z)P7b2&hSj#hpY`deEHNhQ4&nG>89`J0+YB6r=RP#6hf`c5aWr4!g+!n$vrx zpLGGt5EIW^mA*V=dlBSY@zOdw$ZNCiN-6Ce&kuQmOT=_|$&qY~)fql;d{v$KHTrcH z<;k7v5)VKsU!mbCHj;A}ZyR@y)@h!V;Ny?!T$#L>cOAs%1S?!I?NU$zkC$VwvVeHz z=n6S!=8I2GbZS<@IO>Q%>4U!0%F6cZtb*kUm3CQYgnJhRHzukpo}EW{*t}2tsrkY3 zoNe@L7TA`)o@c4>88sxM*%%!H5if_If`DW)zh2i<$V?420$eN*s4L0emSCJFH!_P8 z`e+doCsNULb2up^rsTg54Juw|#S2M zklfj)74Cc|3M^-!jLZctvo}kdCb@7X5T6UlG1T_|))#b!Uu5H^fq;(j1Vo`tr0XE1 zg)`lXvok%$Xz4$5ZkCoE(@CwFmiYgn@oeq_bS; z;Nvam+dp?J{u_3I3ROHIkYavSJuvqx)653bDe2Vgm(jn*@S}dO7x&^>ch}IDbSgoK zI#OE>6y+-BGeeEyODl1GSNKe&L7d7AJmvmplUQ0$KeGUjEb#N$hELDN z`BCf;6NsVj^-v|}8TygSv@b3KiUD!Xd|3HcicS1%K*oArs0W@+n2c?Z0+`Jj=&s*& zz2JY}{M=J?_+WFcvn^y89)1Rd^c3(wUod_KZLVUIq0f^HAX$TkNb8P+7N3qM1){}c z8Bvxez}Zk4Db(Ixhyygg0%QqD1*C(&JqRSnLc_9v=1gU 0 && event.CreatedAt().Add(expiry).Before(time.Now().UTC()) { return true, nil } return u.queries.IsAlreadyHandled(ctx, event, data, eventTypes...) diff --git a/internal/notification/handlers/user_notifier_test.go b/internal/notification/handlers/user_notifier_test.go index c18ddc1df3..d0151b6d7e 100644 --- a/internal/notification/handlers/user_notifier_test.go +++ b/internal/notification/handlers/user_notifier_test.go @@ -7,11 +7,13 @@ import ( "testing" "time" + "github.com/muhlemmer/gu" "github.com/stretchr/testify/assert" "go.uber.org/mock/gomock" "golang.org/x/text/language" "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/eventstore/repository" es_repo_mock "github.com/zitadel/zitadel/internal/eventstore/repository/mock" @@ -19,6 +21,7 @@ import ( channel_mock "github.com/zitadel/zitadel/internal/notification/channels/mock" "github.com/zitadel/zitadel/internal/notification/channels/sms" "github.com/zitadel/zitadel/internal/notification/channels/smtp" + "github.com/zitadel/zitadel/internal/notification/channels/twilio" "github.com/zitadel/zitadel/internal/notification/channels/webhook" "github.com/zitadel/zitadel/internal/notification/handlers/mock" "github.com/zitadel/zitadel/internal/notification/messages" @@ -36,10 +39,13 @@ const ( codeID = "event1" logoURL = "logo.png" eventOrigin = "https://triggered.here" + eventOriginDomain = "triggered.here" assetsPath = "/assets/v1" preferredLoginName = "loginName1" lastEmail = "last@email.com" verifiedEmail = "verified@email.com" + lastPhone = "+41797654321" + verifiedPhone = "+41791234567" instancePrimaryDomain = "primary.domain" externalDomain = "external.domain" externalPort = 3000 @@ -47,6 +53,9 @@ const ( externalProtocol = "http" defaultOTPEmailTemplate = "/otp/verify?loginName={{.LoginName}}&code={{.Code}}" authRequestID = "authRequestID" + smsProviderID = "smsProviderID" + emailProviderID = "emailProviderID" + verificationID = "verificationID" ) func Test_userNotifier_reduceInitCodeAdded(t *testing.T) { @@ -59,7 +68,7 @@ func Test_userNotifier_reduceInitCodeAdded(t *testing.T) { test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { givenTemplate := "{{.LogoURL}}" expectContent := fmt.Sprintf("%s%s/%s/%s", eventOrigin, assetsPath, policyID, logoURL) - w.message = messages.Email{ + w.message = &messages.Email{ Recipients: []string{lastEmail}, Subject: expectMailSubject, Content: expectContent, @@ -92,7 +101,7 @@ func Test_userNotifier_reduceInitCodeAdded(t *testing.T) { test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { givenTemplate := "{{.LogoURL}}" expectContent := fmt.Sprintf("%s://%s:%d%s/%s/%s", externalProtocol, instancePrimaryDomain, externalPort, assetsPath, policyID, logoURL) - w.message = messages.Email{ + w.message = &messages.Email{ Recipients: []string{lastEmail}, Subject: expectMailSubject, Content: expectContent, @@ -131,7 +140,7 @@ func Test_userNotifier_reduceInitCodeAdded(t *testing.T) { givenTemplate := "{{.URL}}" testCode := "testcode" expectContent := fmt.Sprintf("%s/ui/login/user/init?authRequestID=%s&code=%s&loginname=%s&orgID=%s&passwordset=%t&userID=%s", eventOrigin, "", testCode, preferredLoginName, orgID, false, userID) - w.message = messages.Email{ + w.message = &messages.Email{ Recipients: []string{lastEmail}, Subject: expectMailSubject, Content: expectContent, @@ -165,7 +174,7 @@ func Test_userNotifier_reduceInitCodeAdded(t *testing.T) { givenTemplate := "{{.URL}}" testCode := "testcode" expectContent := fmt.Sprintf("%s://%s:%d/ui/login/user/init?authRequestID=%s&code=%s&loginname=%s&orgID=%s&passwordset=%t&userID=%s", externalProtocol, instancePrimaryDomain, externalPort, "", testCode, preferredLoginName, orgID, false, userID) - w.message = messages.Email{ + w.message = &messages.Email{ Recipients: []string{lastEmail}, Subject: expectMailSubject, Content: expectContent, @@ -204,7 +213,7 @@ func Test_userNotifier_reduceInitCodeAdded(t *testing.T) { givenTemplate := "{{.URL}}" testCode := "testcode" expectContent := fmt.Sprintf("%s://%s:%d/ui/login/user/init?authRequestID=%s&code=%s&loginname=%s&orgID=%s&passwordset=%t&userID=%s", externalProtocol, instancePrimaryDomain, externalPort, authRequestID, testCode, preferredLoginName, orgID, false, userID) - w.message = messages.Email{ + w.message = &messages.Email{ Recipients: []string{lastEmail}, Subject: expectMailSubject, Content: expectContent, @@ -272,7 +281,7 @@ func Test_userNotifier_reduceEmailCodeAdded(t *testing.T) { test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { givenTemplate := "{{.LogoURL}}" expectContent := fmt.Sprintf("%s%s/%s/%s", eventOrigin, assetsPath, policyID, logoURL) - w.message = messages.Email{ + w.message = &messages.Email{ Recipients: []string{lastEmail}, Subject: expectMailSubject, Content: expectContent, @@ -307,7 +316,7 @@ func Test_userNotifier_reduceEmailCodeAdded(t *testing.T) { test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { givenTemplate := "{{.LogoURL}}" expectContent := fmt.Sprintf("%s://%s:%d%s/%s/%s", externalProtocol, instancePrimaryDomain, externalPort, assetsPath, policyID, logoURL) - w.message = messages.Email{ + w.message = &messages.Email{ Recipients: []string{lastEmail}, Subject: expectMailSubject, Content: expectContent, @@ -348,7 +357,7 @@ func Test_userNotifier_reduceEmailCodeAdded(t *testing.T) { givenTemplate := "{{.URL}}" testCode := "testcode" expectContent := fmt.Sprintf("%s/ui/login/mail/verification?authRequestID=%s&code=%s&orgID=%s&userID=%s", eventOrigin, "", testCode, orgID, userID) - w.message = messages.Email{ + w.message = &messages.Email{ Recipients: []string{lastEmail}, Subject: expectMailSubject, Content: expectContent, @@ -385,7 +394,7 @@ func Test_userNotifier_reduceEmailCodeAdded(t *testing.T) { givenTemplate := "{{.URL}}" testCode := "testcode" expectContent := fmt.Sprintf("%s://%s:%d/ui/login/mail/verification?authRequestID=%s&code=%s&orgID=%s&userID=%s", externalProtocol, instancePrimaryDomain, externalPort, "", testCode, orgID, userID) - w.message = messages.Email{ + w.message = &messages.Email{ Recipients: []string{lastEmail}, Subject: expectMailSubject, Content: expectContent, @@ -426,7 +435,7 @@ func Test_userNotifier_reduceEmailCodeAdded(t *testing.T) { givenTemplate := "{{.URL}}" testCode := "testcode" expectContent := fmt.Sprintf("%s://%s:%d/ui/login/mail/verification?authRequestID=%s&code=%s&orgID=%s&userID=%s", externalProtocol, instancePrimaryDomain, externalPort, authRequestID, testCode, orgID, userID) - w.message = messages.Email{ + w.message = &messages.Email{ Recipients: []string{lastEmail}, Subject: expectMailSubject, Content: expectContent, @@ -469,7 +478,7 @@ func Test_userNotifier_reduceEmailCodeAdded(t *testing.T) { urlTemplate := "https://my.custom.url/org/{{.OrgID}}/user/{{.UserID}}/verify/{{.Code}}" testCode := "testcode" expectContent := fmt.Sprintf("https://my.custom.url/org/%s/user/%s/verify/%s", orgID, userID, testCode) - w.message = messages.Email{ + w.message = &messages.Email{ Recipients: []string{lastEmail}, Subject: expectMailSubject, Content: expectContent, @@ -533,14 +542,14 @@ func Test_userNotifier_reducePasswordCodeAdded(t *testing.T) { test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { givenTemplate := "{{.LogoURL}}" expectContent := fmt.Sprintf("%s%s/%s/%s", eventOrigin, assetsPath, policyID, logoURL) - w.message = messages.Email{ + w.message = &messages.Email{ Recipients: []string{lastEmail}, Subject: expectMailSubject, Content: expectContent, } codeAlg, code := cryptoValue(t, ctrl, "testcode") expectTemplateQueries(queries, givenTemplate) - commands.EXPECT().PasswordCodeSent(gomock.Any(), orgID, userID).Return(nil) + commands.EXPECT().PasswordCodeSent(gomock.Any(), orgID, userID, &senders.CodeGeneratorInfo{}).Return(nil) return fields{ queries: queries, commands: commands, @@ -568,7 +577,7 @@ func Test_userNotifier_reducePasswordCodeAdded(t *testing.T) { test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { givenTemplate := "{{.LogoURL}}" expectContent := fmt.Sprintf("%s://%s:%d%s/%s/%s", externalProtocol, instancePrimaryDomain, externalPort, assetsPath, policyID, logoURL) - w.message = messages.Email{ + w.message = &messages.Email{ Recipients: []string{lastEmail}, Subject: expectMailSubject, Content: expectContent, @@ -581,7 +590,7 @@ func Test_userNotifier_reducePasswordCodeAdded(t *testing.T) { }}, }, nil) expectTemplateQueries(queries, givenTemplate) - commands.EXPECT().PasswordCodeSent(gomock.Any(), orgID, userID).Return(nil) + commands.EXPECT().PasswordCodeSent(gomock.Any(), orgID, userID, &senders.CodeGeneratorInfo{}).Return(nil) return fields{ queries: queries, commands: commands, @@ -609,14 +618,14 @@ func Test_userNotifier_reducePasswordCodeAdded(t *testing.T) { givenTemplate := "{{.URL}}" testCode := "testcode" expectContent := fmt.Sprintf("%s/ui/login/password/init?authRequestID=%s&code=%s&orgID=%s&userID=%s", eventOrigin, "", testCode, orgID, userID) - w.message = messages.Email{ + w.message = &messages.Email{ Recipients: []string{lastEmail}, Subject: expectMailSubject, Content: expectContent, } codeAlg, code := cryptoValue(t, ctrl, testCode) expectTemplateQueries(queries, givenTemplate) - commands.EXPECT().PasswordCodeSent(gomock.Any(), orgID, userID).Return(nil) + commands.EXPECT().PasswordCodeSent(gomock.Any(), orgID, userID, &senders.CodeGeneratorInfo{}).Return(nil) return fields{ queries: queries, commands: commands, @@ -646,7 +655,7 @@ func Test_userNotifier_reducePasswordCodeAdded(t *testing.T) { givenTemplate := "{{.URL}}" testCode := "testcode" expectContent := fmt.Sprintf("%s://%s:%d/ui/login/password/init?authRequestID=%s&code=%s&orgID=%s&userID=%s", externalProtocol, instancePrimaryDomain, externalPort, "", testCode, orgID, userID) - w.message = messages.Email{ + w.message = &messages.Email{ Recipients: []string{lastEmail}, Subject: expectMailSubject, Content: expectContent, @@ -659,7 +668,7 @@ func Test_userNotifier_reducePasswordCodeAdded(t *testing.T) { }}, }, nil) expectTemplateQueries(queries, givenTemplate) - commands.EXPECT().PasswordCodeSent(gomock.Any(), orgID, userID).Return(nil) + commands.EXPECT().PasswordCodeSent(gomock.Any(), orgID, userID, &senders.CodeGeneratorInfo{}).Return(nil) return fields{ queries: queries, commands: commands, @@ -687,7 +696,7 @@ func Test_userNotifier_reducePasswordCodeAdded(t *testing.T) { givenTemplate := "{{.URL}}" testCode := "testcode" expectContent := fmt.Sprintf("%s://%s:%d/ui/login/password/init?authRequestID=%s&code=%s&orgID=%s&userID=%s", externalProtocol, instancePrimaryDomain, externalPort, authRequestID, testCode, orgID, userID) - w.message = messages.Email{ + w.message = &messages.Email{ Recipients: []string{lastEmail}, Subject: expectMailSubject, Content: expectContent, @@ -700,7 +709,7 @@ func Test_userNotifier_reducePasswordCodeAdded(t *testing.T) { }}, }, nil) expectTemplateQueries(queries, givenTemplate) - commands.EXPECT().PasswordCodeSent(gomock.Any(), orgID, userID).Return(nil) + commands.EXPECT().PasswordCodeSent(gomock.Any(), orgID, userID, &senders.CodeGeneratorInfo{}).Return(nil) return fields{ queries: queries, commands: commands, @@ -730,14 +739,14 @@ func Test_userNotifier_reducePasswordCodeAdded(t *testing.T) { urlTemplate := "https://my.custom.url/org/{{.OrgID}}/user/{{.UserID}}/verify/{{.Code}}" testCode := "testcode" expectContent := fmt.Sprintf("https://my.custom.url/org/%s/user/%s/verify/%s", orgID, userID, testCode) - w.message = messages.Email{ + w.message = &messages.Email{ Recipients: []string{lastEmail}, Subject: expectMailSubject, Content: expectContent, } codeAlg, code := cryptoValue(t, ctrl, testCode) expectTemplateQueries(queries, givenTemplate) - commands.EXPECT().PasswordCodeSent(gomock.Any(), orgID, userID).Return(nil) + commands.EXPECT().PasswordCodeSent(gomock.Any(), orgID, userID, &senders.CodeGeneratorInfo{}).Return(nil) return fields{ queries: queries, commands: commands, @@ -761,7 +770,44 @@ func Test_userNotifier_reducePasswordCodeAdded(t *testing.T) { }, }, w }, - }} + }, { + name: "external code", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { + givenTemplate := "{{.URL}}" + expectContent := "We received a password reset request. Please use the button below to reset your password. (Code ) If you didn't ask for this mail, please ignore it." + w.messageSMS = &messages.SMS{ + SenderPhoneNumber: "senderNumber", + RecipientPhoneNumber: lastPhone, + Content: expectContent, + } + expectTemplateQueries(queries, givenTemplate) + commands.EXPECT().PasswordCodeSent(gomock.Any(), orgID, userID, &senders.CodeGeneratorInfo{ID: smsProviderID, VerificationID: verificationID}).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(&eventstore.Config{ + Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, + }), + SMSTokenCrypto: nil, + }, args{ + event: &user.HumanPasswordCodeAddedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + }), + Code: nil, + Expiry: 0, + URLTemplate: "", + CodeReturned: false, + NotificationType: domain.NotificationTypeSms, + GeneratorID: smsProviderID, + TriggeredAtOrigin: eventOrigin, + }, + }, w + }, + }, + } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ctrl := gomock.NewController(t) @@ -794,7 +840,7 @@ func Test_userNotifier_reduceDomainClaimed(t *testing.T) { test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { givenTemplate := "{{.LogoURL}}" expectContent := fmt.Sprintf("%s%s/%s/%s", eventOrigin, assetsPath, policyID, logoURL) - w.message = messages.Email{ + w.message = &messages.Email{ Recipients: []string{lastEmail}, Subject: expectMailSubject, Content: expectContent, @@ -823,7 +869,7 @@ func Test_userNotifier_reduceDomainClaimed(t *testing.T) { test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { givenTemplate := "{{.LogoURL}}" expectContent := fmt.Sprintf("%s://%s:%d%s/%s/%s", externalProtocol, instancePrimaryDomain, externalPort, assetsPath, policyID, logoURL) - w.message = messages.Email{ + w.message = &messages.Email{ Recipients: []string{lastEmail}, Subject: expectMailSubject, Content: expectContent, @@ -885,7 +931,7 @@ func Test_userNotifier_reducePasswordlessCodeRequested(t *testing.T) { test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { givenTemplate := "{{.LogoURL}}" expectContent := fmt.Sprintf("%s%s/%s/%s", eventOrigin, assetsPath, policyID, logoURL) - w.message = messages.Email{ + w.message = &messages.Email{ Recipients: []string{lastEmail}, Subject: expectMailSubject, Content: expectContent, @@ -921,7 +967,7 @@ func Test_userNotifier_reducePasswordlessCodeRequested(t *testing.T) { test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { givenTemplate := "{{.LogoURL}}" expectContent := fmt.Sprintf("%s://%s:%d%s/%s/%s", externalProtocol, instancePrimaryDomain, externalPort, assetsPath, policyID, logoURL) - w.message = messages.Email{ + w.message = &messages.Email{ Recipients: []string{lastEmail}, Subject: expectMailSubject, Content: expectContent, @@ -964,7 +1010,7 @@ func Test_userNotifier_reducePasswordlessCodeRequested(t *testing.T) { testCode := "testcode" codeAlg, code := cryptoValue(t, ctrl, testCode) expectContent := fmt.Sprintf("%s/ui/login/login/passwordless/init?userID=%s&orgID=%s&codeID=%s&code=%s", eventOrigin, userID, orgID, codeID, testCode) - w.message = messages.Email{ + w.message = &messages.Email{ Recipients: []string{lastEmail}, Subject: expectMailSubject, Content: expectContent, @@ -1002,7 +1048,7 @@ func Test_userNotifier_reducePasswordlessCodeRequested(t *testing.T) { testCode := "testcode" codeAlg, code := cryptoValue(t, ctrl, testCode) expectContent := fmt.Sprintf("%s://%s:%d/ui/login/login/passwordless/init?userID=%s&orgID=%s&codeID=%s&code=%s", externalProtocol, instancePrimaryDomain, externalPort, userID, orgID, codeID, testCode) - w.message = messages.Email{ + w.message = &messages.Email{ Recipients: []string{lastEmail}, Subject: expectMailSubject, Content: expectContent, @@ -1044,7 +1090,7 @@ func Test_userNotifier_reducePasswordlessCodeRequested(t *testing.T) { urlTemplate := "https://my.custom.url/org/{{.OrgID}}/user/{{.UserID}}/verify/{{.Code}}" testCode := "testcode" expectContent := fmt.Sprintf("https://my.custom.url/org/%s/user/%s/verify/%s", orgID, userID, testCode) - w.message = messages.Email{ + w.message = &messages.Email{ Recipients: []string{lastEmail}, Subject: expectMailSubject, Content: expectContent, @@ -1109,7 +1155,7 @@ func Test_userNotifier_reducePasswordChanged(t *testing.T) { test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { givenTemplate := "{{.LogoURL}}" expectContent := fmt.Sprintf("%s%s/%s/%s", eventOrigin, assetsPath, policyID, logoURL) - w.message = messages.Email{ + w.message = &messages.Email{ Recipients: []string{lastEmail}, Subject: expectMailSubject, Content: expectContent, @@ -1141,7 +1187,7 @@ func Test_userNotifier_reducePasswordChanged(t *testing.T) { test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { givenTemplate := "{{.LogoURL}}" expectContent := fmt.Sprintf("%s://%s:%d%s/%s/%s", externalProtocol, instancePrimaryDomain, externalPort, assetsPath, policyID, logoURL) - w.message = messages.Email{ + w.message = &messages.Email{ Recipients: []string{lastEmail}, Subject: expectMailSubject, Content: expectContent, @@ -1206,7 +1252,7 @@ func Test_userNotifier_reduceOTPEmailChallenged(t *testing.T) { test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { givenTemplate := "{{.LogoURL}}" expectContent := fmt.Sprintf("%s%s/%s/%s", eventOrigin, assetsPath, policyID, logoURL) - w.message = messages.Email{ + w.message = &messages.Email{ Recipients: []string{verifiedEmail}, Subject: expectMailSubject, Content: expectContent, @@ -1242,7 +1288,7 @@ func Test_userNotifier_reduceOTPEmailChallenged(t *testing.T) { test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { givenTemplate := "{{.LogoURL}}" expectContent := fmt.Sprintf("%s://%s:%d%s/%s/%s", externalProtocol, instancePrimaryDomain, externalPort, assetsPath, policyID, logoURL) - w.message = messages.Email{ + w.message = &messages.Email{ Recipients: []string{verifiedEmail}, Subject: expectMailSubject, Content: expectContent, @@ -1284,7 +1330,7 @@ func Test_userNotifier_reduceOTPEmailChallenged(t *testing.T) { givenTemplate := "{{.URL}}" testCode := "testcode" expectContent := fmt.Sprintf("%s/otp/verify?loginName=%s&code=%s", eventOrigin, preferredLoginName, testCode) - w.message = messages.Email{ + w.message = &messages.Email{ Recipients: []string{verifiedEmail}, Subject: expectMailSubject, Content: expectContent, @@ -1322,7 +1368,7 @@ func Test_userNotifier_reduceOTPEmailChallenged(t *testing.T) { givenTemplate := "{{.URL}}" testCode := "testcode" expectContent := fmt.Sprintf("%s://%s:%d/otp/verify?loginName=%s&code=%s", externalProtocol, instancePrimaryDomain, externalPort, preferredLoginName, testCode) - w.message = messages.Email{ + w.message = &messages.Email{ Recipients: []string{verifiedEmail}, Subject: expectMailSubject, Content: expectContent, @@ -1364,7 +1410,7 @@ func Test_userNotifier_reduceOTPEmailChallenged(t *testing.T) { urlTemplate := "https://my.custom.url/user/{{.LoginName}}/verify" testCode := "testcode" expectContent := fmt.Sprintf("https://my.custom.url/user/%s/verify", preferredLoginName) - w.message = messages.Email{ + w.message = &messages.Email{ Recipients: []string{verifiedEmail}, Subject: expectMailSubject, Content: expectContent, @@ -1413,6 +1459,107 @@ func Test_userNotifier_reduceOTPEmailChallenged(t *testing.T) { } } +func Test_userNotifier_reduceOTPSMSChallenged(t *testing.T) { + tests := []struct { + name string + test func(*gomock.Controller, *mock.MockQueries, *mock.MockCommands) (fields, args, want) + }{{ + name: "asset url with event trigger url", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { + givenTemplate := "{{.LogoURL}}" + testCode := "" + expiry := 0 * time.Hour + expectContent := fmt.Sprintf(`%[1]s is your one-time-password for %[2]s. Use it within the next %[3]s. +@%[2]s #%[1]s`, testCode, eventOriginDomain, expiry) + w.messageSMS = &messages.SMS{ + SenderPhoneNumber: "senderNumber", + RecipientPhoneNumber: verifiedPhone, + Content: expectContent, + } + expectTemplateQueriesSMS(queries, givenTemplate) + queries.EXPECT().SessionByID(gomock.Any(), gomock.Any(), userID, gomock.Any()).Return(&query.Session{}, nil) + commands.EXPECT().OTPSMSSent(gomock.Any(), userID, orgID, &senders.CodeGeneratorInfo{ID: smsProviderID, VerificationID: verificationID}).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(&eventstore.Config{ + Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, + }), + }, args{ + event: &session.OTPSMSChallengedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + }), + Code: nil, + Expiry: expiry, + CodeReturned: false, + GeneratorID: smsProviderID, + TriggeredAtOrigin: eventOrigin, + }, + }, w + }, + }, { + name: "asset url without event trigger url", + test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fields, a args, w want) { + givenTemplate := "{{.LogoURL}}" + testCode := "" + expiry := 0 * time.Hour + expectContent := fmt.Sprintf(`%[1]s is your one-time-password for %[2]s. Use it within the next %[3]s. +@%[2]s #%[1]s`, testCode, instancePrimaryDomain, expiry) + w.messageSMS = &messages.SMS{ + SenderPhoneNumber: "senderNumber", + RecipientPhoneNumber: verifiedPhone, + Content: expectContent, + } + expectTemplateQueriesSMS(queries, givenTemplate) + queries.EXPECT().SessionByID(gomock.Any(), gomock.Any(), userID, gomock.Any()).Return(&query.Session{}, nil) + queries.EXPECT().SearchInstanceDomains(gomock.Any(), gomock.Any()).Return(&query.InstanceDomains{ + Domains: []*query.InstanceDomain{{ + Domain: instancePrimaryDomain, + IsPrimary: true, + }}, + }, nil) + commands.EXPECT().OTPSMSSent(gomock.Any(), userID, orgID, &senders.CodeGeneratorInfo{ID: smsProviderID, VerificationID: verificationID}).Return(nil) + return fields{ + queries: queries, + commands: commands, + es: eventstore.NewEventstore(&eventstore.Config{ + Querier: es_repo_mock.NewRepo(t).ExpectFilterEvents().MockQuerier, + }), + }, args{ + event: &session.OTPSMSChallengedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{ + AggregateID: userID, + ResourceOwner: sql.NullString{String: orgID}, + CreationDate: time.Now().UTC(), + }), + Code: nil, + Expiry: expiry, + CodeReturned: false, + GeneratorID: smsProviderID, + }, + }, w + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + queries := mock.NewMockQueries(ctrl) + commands := mock.NewMockCommands(ctrl) + f, a, w := tt.test(ctrl, queries, commands) + _, err := newUserNotifier(t, ctrl, queries, f, a, w).reduceSessionOTPSMSChallenged(a.event) + if w.err != nil { + w.err(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + type fields struct { queries *mock.MockQueries commands *mock.MockCommands @@ -1424,8 +1571,9 @@ type args struct { event eventstore.Event } type want struct { - message messages.Email - err assert.ErrorAssertionFunc + message *messages.Email + messageSMS *messages.SMS + err assert.ErrorAssertionFunc } func newUserNotifier(t *testing.T, ctrl *gomock.Controller, queries *mock.MockQueries, f fields, a args, w want) *userNotifier { @@ -1433,8 +1581,17 @@ func newUserNotifier(t *testing.T, ctrl *gomock.Controller, queries *mock.MockQu smtpAlg, _ := cryptoValue(t, ctrl, "smtppw") channel := channel_mock.NewMockNotificationChannel(ctrl) if w.err == nil { - w.message.TriggeringEvent = a.event - channel.EXPECT().HandleMessage(&w.message).Return(nil) + if w.message != nil { + w.message.TriggeringEvent = a.event + channel.EXPECT().HandleMessage(w.message).Return(nil) + } + if w.messageSMS != nil { + w.messageSMS.TriggeringEvent = a.event + channel.EXPECT().HandleMessage(w.messageSMS).DoAndReturn(func(message *messages.SMS) error { + message.VerificationID = gu.Ptr(verificationID) + return nil + }) + } } return &userNotifier{ commands: f.commands, @@ -1454,8 +1611,8 @@ func newUserNotifier(t *testing.T, ctrl *gomock.Controller, queries *mock.MockQu Chain: *senders.ChainChannels(channel), EmailConfig: &email.Config{ ProviderConfig: &email.Provider{ - ID: "ID", - Description: "Description", + ID: "emailProviderID", + Description: "description", }, SMTPConfig: &smtp.Config{ SMTP: smtp.SMTP{ @@ -1470,6 +1627,18 @@ func newUserNotifier(t *testing.T, ctrl *gomock.Controller, queries *mock.MockQu }, WebhookConfig: nil, }, + SMSConfig: &sms.Config{ + ProviderConfig: &sms.Provider{ + ID: "smsProviderID", + Description: "description", + }, + TwilioConfig: &twilio.Config{ + SID: "sid", + Token: "token", + SenderNumber: "senderNumber", + VerifyServiceSID: "verifyServiceSID", + }, + }, }, } } @@ -1479,6 +1648,7 @@ var _ types.ChannelChains = (*channels)(nil) type channels struct { senders.Chain EmailConfig *email.Config + SMSConfig *sms.Config } func (c *channels) Email(context.Context) (*senders.Chain, *email.Config, error) { @@ -1486,7 +1656,7 @@ func (c *channels) Email(context.Context) (*senders.Chain, *email.Config, error) } func (c *channels) SMS(context.Context) (*senders.Chain, *sms.Config, error) { - return &c.Chain, nil, nil + return &c.Chain, c.SMSConfig, nil } func (c *channels) Webhook(context.Context, webhook.Config) (*senders.Chain, error) { @@ -1510,6 +1680,31 @@ func expectTemplateQueries(queries *mock.MockQueries, template string) { LastEmail: lastEmail, VerifiedEmail: verifiedEmail, PreferredLoginName: preferredLoginName, + LastPhone: lastPhone, + VerifiedPhone: verifiedPhone, + }, nil) + queries.EXPECT().GetDefaultLanguage(gomock.Any()).Return(language.English) + queries.EXPECT().CustomTextListByTemplate(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(2).Return(&query.CustomTexts{}, nil) +} + +func expectTemplateQueriesSMS(queries *mock.MockQueries, template string) { + queries.EXPECT().GetInstanceRestrictions(gomock.Any()).Return(query.Restrictions{ + AllowedLanguages: []language.Tag{language.English}, + }, nil) + queries.EXPECT().ActiveLabelPolicyByOrg(gomock.Any(), gomock.Any(), gomock.Any()).Return(&query.LabelPolicy{ + ID: policyID, + Light: query.Theme{ + LogoURL: logoURL, + }, + }, nil) + queries.EXPECT().GetNotifyUserByID(gomock.Any(), gomock.Any(), gomock.Any()).Return(&query.NotifyUser{ + ID: userID, + ResourceOwner: orgID, + LastEmail: lastEmail, + VerifiedEmail: verifiedEmail, + PreferredLoginName: preferredLoginName, + LastPhone: lastPhone, + VerifiedPhone: verifiedPhone, }, nil) queries.EXPECT().GetDefaultLanguage(gomock.Any()).Return(language.English) queries.EXPECT().CustomTextListByTemplate(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(2).Return(&query.CustomTexts{}, nil) diff --git a/internal/notification/messages/sms.go b/internal/notification/messages/sms.go index 72c377b337..0dfaea8772 100644 --- a/internal/notification/messages/sms.go +++ b/internal/notification/messages/sms.go @@ -12,6 +12,9 @@ type SMS struct { RecipientPhoneNumber string Content string TriggeringEvent eventstore.Event + + // VerificationID is set by the sender + VerificationID *string } func (msg *SMS) GetContent() (string, error) { diff --git a/internal/notification/senders/code_verifier.go b/internal/notification/senders/code_verifier.go new file mode 100644 index 0000000000..3aa9ec2e0e --- /dev/null +++ b/internal/notification/senders/code_verifier.go @@ -0,0 +1,24 @@ +package senders + +type CodeGenerator interface { + VerifyCode(verificationID, code string) error +} + +type CodeGeneratorInfo struct { + ID string `json:"id,omitempty"` + VerificationID string `json:"verificationId,omitempty"` +} + +func (c *CodeGeneratorInfo) GetID() string { + if c == nil { + return "" + } + return c.ID +} + +func (c *CodeGeneratorInfo) GetVerificationID() string { + if c == nil { + return "" + } + return c.VerificationID +} diff --git a/internal/notification/senders/gen_mock.go b/internal/notification/senders/gen_mock.go new file mode 100644 index 0000000000..5a0f472859 --- /dev/null +++ b/internal/notification/senders/gen_mock.go @@ -0,0 +1,3 @@ +package senders + +//go:generate mockgen -package mock -destination ./mock/code_generator.mock.go github.com/zitadel/zitadel/internal/notification/senders CodeGenerator diff --git a/internal/notification/senders/mock/code_generator.mock.go b/internal/notification/senders/mock/code_generator.mock.go new file mode 100644 index 0000000000..15bdd2cc31 --- /dev/null +++ b/internal/notification/senders/mock/code_generator.mock.go @@ -0,0 +1,53 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/zitadel/zitadel/internal/notification/senders (interfaces: CodeGenerator) +// +// Generated by this command: +// +// mockgen -package mock -destination ./mock/code_generator.mock.go github.com/zitadel/zitadel/internal/notification/senders CodeGenerator +// + +// Package mock is a generated GoMock package. +package mock + +import ( + reflect "reflect" + + gomock "go.uber.org/mock/gomock" +) + +// MockCodeGenerator is a mock of CodeGenerator interface. +type MockCodeGenerator struct { + ctrl *gomock.Controller + recorder *MockCodeGeneratorMockRecorder +} + +// MockCodeGeneratorMockRecorder is the mock recorder for MockCodeGenerator. +type MockCodeGeneratorMockRecorder struct { + mock *MockCodeGenerator +} + +// NewMockCodeGenerator creates a new mock instance. +func NewMockCodeGenerator(ctrl *gomock.Controller) *MockCodeGenerator { + mock := &MockCodeGenerator{ctrl: ctrl} + mock.recorder = &MockCodeGeneratorMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockCodeGenerator) EXPECT() *MockCodeGeneratorMockRecorder { + return m.recorder +} + +// VerifyCode mocks base method. +func (m *MockCodeGenerator) VerifyCode(arg0, arg1 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "VerifyCode", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// VerifyCode indicates an expected call of VerifyCode. +func (mr *MockCodeGeneratorMockRecorder) VerifyCode(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "VerifyCode", reflect.TypeOf((*MockCodeGenerator)(nil).VerifyCode), arg0, arg1) +} diff --git a/internal/notification/types/notification.go b/internal/notification/types/notification.go index 8d1b013164..49a437ff18 100644 --- a/internal/notification/types/notification.go +++ b/internal/notification/types/notification.go @@ -87,6 +87,7 @@ func SendSMS( user *query.NotifyUser, colors *query.LabelPolicy, triggeringEvent eventstore.Event, + generatorInfo *senders.CodeGeneratorInfo, ) Notify { return func( url string, @@ -104,6 +105,7 @@ func SendSMS( args, allowUnverifiedNotificationChannel, triggeringEvent, + generatorInfo, ) } } diff --git a/internal/notification/types/user_phone.go b/internal/notification/types/user_phone.go index 8e79f73718..0016f0f7a4 100644 --- a/internal/notification/types/user_phone.go +++ b/internal/notification/types/user_phone.go @@ -8,6 +8,7 @@ import ( "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/notification/messages" + "github.com/zitadel/zitadel/internal/notification/senders" "github.com/zitadel/zitadel/internal/notification/templates" "github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/internal/zerrors" @@ -27,6 +28,7 @@ func generateSms( args map[string]interface{}, lastPhone bool, triggeringEvent eventstore.Event, + generatorInfo *senders.CodeGeneratorInfo, ) error { smsChannels, config, err := channels.SMS(ctx) logging.OnError(err).Error("could not create sms channel") @@ -48,7 +50,15 @@ func generateSms( Content: data.Text, TriggeringEvent: triggeringEvent, } - return smsChannels.HandleMessage(message) + err = smsChannels.HandleMessage(message) + if err != nil { + return err + } + if config.TwilioConfig.VerifyServiceSID != "" { + generatorInfo.ID = config.ProviderConfig.ID + generatorInfo.VerificationID = *message.VerificationID + } + return nil } if config.WebhookConfig != nil { caseArgs := make(map[string]interface{}, len(args)) diff --git a/internal/query/projection/sms.go b/internal/query/projection/sms.go index 9b157ff992..eb54d7afac 100644 --- a/internal/query/projection/sms.go +++ b/internal/query/projection/sms.go @@ -25,12 +25,13 @@ const ( SMSColumnInstanceID = "instance_id" SMSColumnDescription = "description" - smsTwilioTableSuffix = "twilio" - SMSTwilioColumnSMSID = "sms_id" - SMSTwilioColumnInstanceID = "instance_id" - SMSTwilioColumnSID = "sid" - SMSTwilioColumnSenderNumber = "sender_number" - SMSTwilioColumnToken = "token" + smsTwilioTableSuffix = "twilio" + SMSTwilioColumnSMSID = "sms_id" + SMSTwilioColumnInstanceID = "instance_id" + SMSTwilioColumnSID = "sid" + SMSTwilioColumnSenderNumber = "sender_number" + SMSTwilioColumnToken = "token" + SMSTwilioColumnVerifyServiceSID = "verify_service_sid" smsHTTPTableSuffix = "http" SMSHTTPColumnSMSID = "sms_id" @@ -69,6 +70,7 @@ func (*smsConfigProjection) Init() *old_handler.Check { handler.NewColumn(SMSTwilioColumnSID, handler.ColumnTypeText), handler.NewColumn(SMSTwilioColumnSenderNumber, handler.ColumnTypeText), handler.NewColumn(SMSTwilioColumnToken, handler.ColumnTypeJSONB), + handler.NewColumn(SMSTwilioColumnVerifyServiceSID, handler.ColumnTypeText), }, handler.NewPrimaryKey(SMSTwilioColumnInstanceID, SMSTwilioColumnSMSID), smsTwilioTableSuffix, @@ -172,6 +174,7 @@ func (p *smsConfigProjection) reduceSMSConfigTwilioAdded(event eventstore.Event) handler.NewCol(SMSTwilioColumnSID, e.SID), handler.NewCol(SMSTwilioColumnToken, e.Token), handler.NewCol(SMSTwilioColumnSenderNumber, e.SenderNumber), + handler.NewCol(SMSTwilioColumnVerifyServiceSID, e.VerifyServiceSID), }, handler.WithTableSuffix(smsTwilioTableSuffix), ), @@ -202,13 +205,16 @@ func (p *smsConfigProjection) reduceSMSConfigTwilioChanged(event eventstore.Even )) } - twilioColumns := make([]handler.Column, 0) + twilioColumns := make([]handler.Column, 0, 3) if e.SID != nil { twilioColumns = append(twilioColumns, handler.NewCol(SMSTwilioColumnSID, *e.SID)) } if e.SenderNumber != nil { twilioColumns = append(twilioColumns, handler.NewCol(SMSTwilioColumnSenderNumber, *e.SenderNumber)) } + if e.VerifyServiceSID != nil { + twilioColumns = append(twilioColumns, handler.NewCol(SMSTwilioColumnVerifyServiceSID, *e.VerifyServiceSID)) + } if len(twilioColumns) > 0 { stmts = append(stmts, handler.AddUpdateStatement( twilioColumns, diff --git a/internal/query/projection/sms_test.go b/internal/query/projection/sms_test.go index 88ce6e4417..7a083c2234 100644 --- a/internal/query/projection/sms_test.go +++ b/internal/query/projection/sms_test.go @@ -38,7 +38,8 @@ func TestSMSProjection_reduces(t *testing.T) { "crypted": "Y3J5cHRlZA==" }, "senderNumber": "sender-number", - "description": "description" + "description": "description", + "verifyServiceSid": "verify-service-sid" }`), ), eventstore.GenericEventMapper[instance.SMSConfigTwilioAddedEvent]), }, @@ -63,7 +64,7 @@ func TestSMSProjection_reduces(t *testing.T) { }, }, { - expectedStmt: "INSERT INTO projections.sms_configs3_twilio (sms_id, instance_id, sid, token, sender_number) VALUES ($1, $2, $3, $4, $5)", + expectedStmt: "INSERT INTO projections.sms_configs3_twilio (sms_id, instance_id, sid, token, sender_number, verify_service_sid) VALUES ($1, $2, $3, $4, $5, $6)", expectedArgs: []interface{}{ "id", "instance-id", @@ -75,6 +76,7 @@ func TestSMSProjection_reduces(t *testing.T) { Crypted: []byte("crypted"), }, "sender-number", + "verify-service-sid", }, }, }, @@ -92,7 +94,8 @@ func TestSMSProjection_reduces(t *testing.T) { "id": "id", "sid": "sid", "senderNumber": "sender-number", - "description": "description" + "description": "description", + "verifyServiceSid": "verify-service-sid" }`), ), eventstore.GenericEventMapper[instance.SMSConfigTwilioChangedEvent]), }, @@ -113,10 +116,11 @@ func TestSMSProjection_reduces(t *testing.T) { }, }, { - expectedStmt: "UPDATE projections.sms_configs3_twilio SET (sid, sender_number) = ($1, $2) WHERE (sms_id = $3) AND (instance_id = $4)", + expectedStmt: "UPDATE projections.sms_configs3_twilio SET (sid, sender_number, verify_service_sid) = ($1, $2, $3) WHERE (sms_id = $4) AND (instance_id = $5)", expectedArgs: []interface{}{ "sid", "sender-number", + "verify-service-sid", "id", "instance-id", }, @@ -248,6 +252,46 @@ func TestSMSProjection_reduces(t *testing.T) { }, }, }, + { + name: "instance reduceSMSConfigTwilioChanged, only sid", + args: args{ + event: getEvent( + testEvent( + instance.SMSConfigTwilioChangedEventType, + instance.AggregateType, + []byte(`{ + "id": "id", + "verifyServiceSid": "verify-service-sid" + }`), + ), eventstore.GenericEventMapper[instance.SMSConfigTwilioChangedEvent]), + }, + reduce: (&smsConfigProjection{}).reduceSMSConfigTwilioChanged, + want: wantReduce{ + aggregateType: eventstore.AggregateType("instance"), + sequence: 15, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "UPDATE projections.sms_configs3 SET (change_date, sequence) = ($1, $2) WHERE (id = $3) AND (instance_id = $4)", + expectedArgs: []interface{}{ + anyArg{}, + uint64(15), + "id", + "instance-id", + }, + }, + { + expectedStmt: "UPDATE projections.sms_configs3_twilio SET verify_service_sid = $1 WHERE (sms_id = $2) AND (instance_id = $3)", + expectedArgs: []interface{}{ + "verify-service-sid", + "id", + "instance-id", + }, + }, + }, + }, + }, + }, { name: "instance reduceSMSHTTPAdded", args: args{ diff --git a/internal/query/sms.go b/internal/query/sms.go index 6f0555634f..ef4d1cfca2 100644 --- a/internal/query/sms.go +++ b/internal/query/sms.go @@ -37,9 +37,10 @@ type SMSConfig struct { } type Twilio struct { - SID string - Token *crypto.CryptoValue - SenderNumber string + SID string + Token *crypto.CryptoValue + SenderNumber string + VerifyServiceSID string } type HTTP struct { @@ -123,6 +124,10 @@ var ( name: projection.SMSTwilioColumnSenderNumber, table: smsTwilioTable, } + SMSTwilioColumnVerifyServiceSID = Column{ + name: projection.SMSTwilioColumnVerifyServiceSID, + table: smsTwilioTable, + } ) var ( @@ -227,6 +232,7 @@ func prepareSMSConfigQuery(ctx context.Context, db prepareDatabase) (sq.SelectBu SMSTwilioColumnSID.identifier(), SMSTwilioColumnToken.identifier(), SMSTwilioColumnSenderNumber.identifier(), + SMSTwilioColumnVerifyServiceSID.identifier(), SMSHTTPColumnSMSID.identifier(), SMSHTTPColumnEndpoint.identifier(), @@ -255,6 +261,7 @@ func prepareSMSConfigQuery(ctx context.Context, db prepareDatabase) (sq.SelectBu &twilioConfig.sid, &twilioConfig.token, &twilioConfig.senderNumber, + &twilioConfig.verifyServiceSid, &httpConfig.id, &httpConfig.endpoint, @@ -289,6 +296,7 @@ func prepareSMSConfigsQuery(ctx context.Context, db prepareDatabase) (sq.SelectB SMSTwilioColumnSID.identifier(), SMSTwilioColumnToken.identifier(), SMSTwilioColumnSenderNumber.identifier(), + SMSTwilioColumnVerifyServiceSID.identifier(), SMSHTTPColumnSMSID.identifier(), SMSHTTPColumnEndpoint.identifier(), @@ -321,6 +329,7 @@ func prepareSMSConfigsQuery(ctx context.Context, db prepareDatabase) (sq.SelectB &twilioConfig.sid, &twilioConfig.token, &twilioConfig.senderNumber, + &twilioConfig.verifyServiceSid, &httpConfig.id, &httpConfig.endpoint, @@ -343,10 +352,11 @@ func prepareSMSConfigsQuery(ctx context.Context, db prepareDatabase) (sq.SelectB } type sqlTwilioConfig struct { - smsID sql.NullString - sid sql.NullString - token *crypto.CryptoValue - senderNumber sql.NullString + smsID sql.NullString + sid sql.NullString + token *crypto.CryptoValue + senderNumber sql.NullString + verifyServiceSid sql.NullString } func (c sqlTwilioConfig) set(smsConfig *SMSConfig) { @@ -354,9 +364,10 @@ func (c sqlTwilioConfig) set(smsConfig *SMSConfig) { return } smsConfig.TwilioConfig = &Twilio{ - SID: c.sid.String, - Token: c.token, - SenderNumber: c.senderNumber.String, + SID: c.sid.String, + Token: c.token, + SenderNumber: c.senderNumber.String, + VerifyServiceSID: c.verifyServiceSid.String, } } diff --git a/internal/query/sms_test.go b/internal/query/sms_test.go index 20cf62f8cb..82c3659f2c 100644 --- a/internal/query/sms_test.go +++ b/internal/query/sms_test.go @@ -28,6 +28,7 @@ var ( ` projections.sms_configs3_twilio.sid,` + ` projections.sms_configs3_twilio.token,` + ` projections.sms_configs3_twilio.sender_number,` + + ` projections.sms_configs3_twilio.verify_service_sid,` + // http config ` projections.sms_configs3_http.sms_id,` + @@ -50,6 +51,7 @@ var ( ` projections.sms_configs3_twilio.sid,` + ` projections.sms_configs3_twilio.token,` + ` projections.sms_configs3_twilio.sender_number,` + + ` projections.sms_configs3_twilio.verify_service_sid,` + // http config ` projections.sms_configs3_http.sms_id,` + @@ -74,6 +76,7 @@ var ( "sid", "token", "sender-number", + "verify_sid", // http config "sms_id", "endpoint", @@ -126,6 +129,7 @@ func Test_SMSConfigsPrepare(t *testing.T) { "sid", &crypto.CryptoValue{}, "sender-number", + "", // http config nil, nil, @@ -148,9 +152,10 @@ func Test_SMSConfigsPrepare(t *testing.T) { Sequence: 20211109, Description: "description", TwilioConfig: &Twilio{ - SID: "sid", - Token: &crypto.CryptoValue{}, - SenderNumber: "sender-number", + SID: "sid", + Token: &crypto.CryptoValue{}, + SenderNumber: "sender-number", + VerifyServiceSID: "", }, }, }, @@ -178,6 +183,7 @@ func Test_SMSConfigsPrepare(t *testing.T) { nil, nil, nil, + nil, // http config "sms-id", "endpoint", @@ -228,6 +234,7 @@ func Test_SMSConfigsPrepare(t *testing.T) { "sid", &crypto.CryptoValue{}, "sender-number", + "verify-service-sid", // http config nil, nil, @@ -246,6 +253,7 @@ func Test_SMSConfigsPrepare(t *testing.T) { "sid2", &crypto.CryptoValue{}, "sender-number2", + "verify-service-sid2", // http config nil, nil, @@ -264,6 +272,7 @@ func Test_SMSConfigsPrepare(t *testing.T) { nil, nil, nil, + nil, // http config "sms-id3", "endpoint3", @@ -286,9 +295,10 @@ func Test_SMSConfigsPrepare(t *testing.T) { Sequence: 20211109, Description: "description", TwilioConfig: &Twilio{ - SID: "sid", - Token: &crypto.CryptoValue{}, - SenderNumber: "sender-number", + SID: "sid", + Token: &crypto.CryptoValue{}, + SenderNumber: "sender-number", + VerifyServiceSID: "verify-service-sid", }, }, { @@ -301,9 +311,10 @@ func Test_SMSConfigsPrepare(t *testing.T) { Sequence: 20211109, Description: "description", TwilioConfig: &Twilio{ - SID: "sid2", - Token: &crypto.CryptoValue{}, - SenderNumber: "sender-number2", + SID: "sid2", + Token: &crypto.CryptoValue{}, + SenderNumber: "sender-number2", + VerifyServiceSID: "verify-service-sid2", }, }, { @@ -397,6 +408,7 @@ func Test_SMSConfigPrepare(t *testing.T) { "sid", &crypto.CryptoValue{}, "sender-number", + "verify-service-sid", // http config nil, nil, @@ -413,9 +425,10 @@ func Test_SMSConfigPrepare(t *testing.T) { Sequence: 20211109, Description: "description", TwilioConfig: &Twilio{ - SID: "sid", - SenderNumber: "sender-number", - Token: &crypto.CryptoValue{}, + SID: "sid", + SenderNumber: "sender-number", + Token: &crypto.CryptoValue{}, + VerifyServiceSID: "verify-service-sid", }, }, }, @@ -440,6 +453,7 @@ func Test_SMSConfigPrepare(t *testing.T) { nil, nil, nil, + nil, // http config "sms-id", "endpoint", diff --git a/internal/repository/instance/sms.go b/internal/repository/instance/sms.go index 309ce9aa46..7a402e67fe 100644 --- a/internal/repository/instance/sms.go +++ b/internal/repository/instance/sms.go @@ -28,11 +28,12 @@ const ( type SMSConfigTwilioAddedEvent struct { *eventstore.BaseEvent `json:"-"` - ID string `json:"id,omitempty"` - Description string `json:"description,omitempty"` - SID string `json:"sid,omitempty"` - Token *crypto.CryptoValue `json:"token,omitempty"` - SenderNumber string `json:"senderNumber,omitempty"` + ID string `json:"id,omitempty"` + Description string `json:"description,omitempty"` + SID string `json:"sid,omitempty"` + Token *crypto.CryptoValue `json:"token,omitempty"` + SenderNumber string `json:"senderNumber,omitempty"` + VerifyServiceSID string `json:"verifyServiceSid,omitempty"` } func NewSMSConfigTwilioAddedEvent( @@ -43,6 +44,7 @@ func NewSMSConfigTwilioAddedEvent( sid, senderNumber string, token *crypto.CryptoValue, + verifyServiceSid string, ) *SMSConfigTwilioAddedEvent { return &SMSConfigTwilioAddedEvent{ BaseEvent: eventstore.NewBaseEventForPush( @@ -50,11 +52,12 @@ func NewSMSConfigTwilioAddedEvent( aggregate, SMSConfigTwilioAddedEventType, ), - ID: id, - Description: description, - SID: sid, - Token: token, - SenderNumber: senderNumber, + ID: id, + Description: description, + SID: sid, + Token: token, + SenderNumber: senderNumber, + VerifyServiceSID: verifyServiceSid, } } @@ -73,10 +76,11 @@ func (e *SMSConfigTwilioAddedEvent) UniqueConstraints() []*eventstore.UniqueCons type SMSConfigTwilioChangedEvent struct { *eventstore.BaseEvent `json:"-"` - ID string `json:"id,omitempty"` - Description *string `json:"description,omitempty"` - SID *string `json:"sid,omitempty"` - SenderNumber *string `json:"senderNumber,omitempty"` + ID string `json:"id,omitempty"` + Description *string `json:"description,omitempty"` + SID *string `json:"sid,omitempty"` + SenderNumber *string `json:"senderNumber,omitempty"` + VerifyServiceSID *string `json:"verifyServiceSid,omitempty"` } func NewSMSConfigTwilioChangedEvent( @@ -122,6 +126,12 @@ func ChangeSMSConfigTwilioSenderNumber(senderNumber string) func(event *SMSConfi } } +func ChangeSMSConfigTwilioVerifyServiceSID(verifyServiceSID string) func(event *SMSConfigTwilioChangedEvent) { + return func(e *SMSConfigTwilioChangedEvent) { + e.VerifyServiceSID = &verifyServiceSID + } +} + func (e *SMSConfigTwilioChangedEvent) SetBaseEvent(event *eventstore.BaseEvent) { e.BaseEvent = event } diff --git a/internal/repository/session/session.go b/internal/repository/session/session.go index 3e9b727f5a..f5622fd4b4 100644 --- a/internal/repository/session/session.go +++ b/internal/repository/session/session.go @@ -10,6 +10,7 @@ import ( "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/notification/senders" "github.com/zitadel/zitadel/internal/zerrors" ) @@ -323,6 +324,7 @@ type OTPSMSChallengedEvent struct { Code *crypto.CryptoValue `json:"code"` Expiry time.Duration `json:"expiry"` CodeReturned bool `json:"codeReturned,omitempty"` + GeneratorID string `json:"generatorId,omitempty"` TriggeredAtOrigin string `json:"triggerOrigin,omitempty"` } @@ -348,6 +350,7 @@ func NewOTPSMSChallengedEvent( code *crypto.CryptoValue, expiry time.Duration, codeReturned bool, + generatorID string, ) *OTPSMSChallengedEvent { return &OTPSMSChallengedEvent{ BaseEvent: *eventstore.NewBaseEventForPush( @@ -358,12 +361,15 @@ func NewOTPSMSChallengedEvent( Code: code, Expiry: expiry, CodeReturned: codeReturned, + GeneratorID: generatorID, TriggeredAtOrigin: http.DomainContext(ctx).Origin(), } } type OTPSMSSentEvent struct { eventstore.BaseEvent `json:"-"` + + GeneratorInfo *senders.CodeGeneratorInfo `json:"generatorInfo,omitempty"` } func (e *OTPSMSSentEvent) Payload() interface{} { @@ -381,6 +387,7 @@ func (e *OTPSMSSentEvent) SetBaseEvent(base *eventstore.BaseEvent) { func NewOTPSMSSentEvent( ctx context.Context, aggregate *eventstore.Aggregate, + generatorInfo *senders.CodeGeneratorInfo, ) *OTPSMSSentEvent { return &OTPSMSSentEvent{ BaseEvent: *eventstore.NewBaseEventForPush( @@ -388,6 +395,7 @@ func NewOTPSMSSentEvent( aggregate, OTPSMSSentType, ), + GeneratorInfo: generatorInfo, } } diff --git a/internal/repository/user/eventstore.go b/internal/repository/user/eventstore.go index 2b726d378a..42bc96280f 100644 --- a/internal/repository/user/eventstore.go +++ b/internal/repository/user/eventstore.go @@ -14,7 +14,7 @@ func init() { eventstore.RegisterFilterEventMapper(AggregateType, UserV1SignedOutType, HumanSignedOutEventMapper) eventstore.RegisterFilterEventMapper(AggregateType, UserV1PasswordChangedType, HumanPasswordChangedEventMapper) eventstore.RegisterFilterEventMapper(AggregateType, UserV1PasswordCodeAddedType, HumanPasswordCodeAddedEventMapper) - eventstore.RegisterFilterEventMapper(AggregateType, UserV1PasswordCodeSentType, HumanPasswordCodeSentEventMapper) + eventstore.RegisterFilterEventMapper(AggregateType, UserV1PasswordCodeSentType, eventstore.GenericEventMapper[HumanPasswordCodeSentEvent]) eventstore.RegisterFilterEventMapper(AggregateType, UserV1PasswordCheckSucceededType, HumanPasswordCheckSucceededEventMapper) eventstore.RegisterFilterEventMapper(AggregateType, UserV1PasswordCheckFailedType, HumanPasswordCheckFailedEventMapper) eventstore.RegisterFilterEventMapper(AggregateType, UserV1EmailChangedType, HumanEmailChangedEventMapper) @@ -27,7 +27,7 @@ func init() { eventstore.RegisterFilterEventMapper(AggregateType, UserV1PhoneVerifiedType, HumanPhoneVerifiedEventMapper) eventstore.RegisterFilterEventMapper(AggregateType, UserV1PhoneVerificationFailedType, HumanPhoneVerificationFailedEventMapper) eventstore.RegisterFilterEventMapper(AggregateType, UserV1PhoneCodeAddedType, HumanPhoneCodeAddedEventMapper) - eventstore.RegisterFilterEventMapper(AggregateType, UserV1PhoneCodeSentType, HumanPhoneCodeSentEventMapper) + eventstore.RegisterFilterEventMapper(AggregateType, UserV1PhoneCodeSentType, eventstore.GenericEventMapper[HumanPhoneCodeSentEvent]) eventstore.RegisterFilterEventMapper(AggregateType, UserV1ProfileChangedType, HumanProfileChangedEventMapper) eventstore.RegisterFilterEventMapper(AggregateType, UserV1AddressChangedType, HumanAddressChangedEventMapper) eventstore.RegisterFilterEventMapper(AggregateType, UserV1MFAInitSkippedType, HumanMFAInitSkippedEventMapper) @@ -60,7 +60,7 @@ func init() { eventstore.RegisterFilterEventMapper(AggregateType, HumanSignedOutType, HumanSignedOutEventMapper) eventstore.RegisterFilterEventMapper(AggregateType, HumanPasswordChangedType, HumanPasswordChangedEventMapper) eventstore.RegisterFilterEventMapper(AggregateType, HumanPasswordCodeAddedType, HumanPasswordCodeAddedEventMapper) - eventstore.RegisterFilterEventMapper(AggregateType, HumanPasswordCodeSentType, HumanPasswordCodeSentEventMapper) + eventstore.RegisterFilterEventMapper(AggregateType, HumanPasswordCodeSentType, eventstore.GenericEventMapper[HumanPasswordCodeSentEvent]) eventstore.RegisterFilterEventMapper(AggregateType, HumanPasswordChangeSentType, HumanPasswordChangeSentEventMapper) eventstore.RegisterFilterEventMapper(AggregateType, HumanPasswordCheckSucceededType, HumanPasswordCheckSucceededEventMapper) eventstore.RegisterFilterEventMapper(AggregateType, HumanPasswordCheckFailedType, HumanPasswordCheckFailedEventMapper) @@ -81,7 +81,7 @@ func init() { eventstore.RegisterFilterEventMapper(AggregateType, HumanPhoneVerifiedType, HumanPhoneVerifiedEventMapper) eventstore.RegisterFilterEventMapper(AggregateType, HumanPhoneVerificationFailedType, HumanPhoneVerificationFailedEventMapper) eventstore.RegisterFilterEventMapper(AggregateType, HumanPhoneCodeAddedType, HumanPhoneCodeAddedEventMapper) - eventstore.RegisterFilterEventMapper(AggregateType, HumanPhoneCodeSentType, HumanPhoneCodeSentEventMapper) + eventstore.RegisterFilterEventMapper(AggregateType, HumanPhoneCodeSentType, eventstore.GenericEventMapper[HumanPhoneCodeSentEvent]) eventstore.RegisterFilterEventMapper(AggregateType, HumanProfileChangedType, HumanProfileChangedEventMapper) eventstore.RegisterFilterEventMapper(AggregateType, HumanAvatarAddedType, HumanAvatarAddedEventMapper) eventstore.RegisterFilterEventMapper(AggregateType, HumanAvatarRemovedType, HumanAvatarRemovedEventMapper) diff --git a/internal/repository/user/human_mfa_otp.go b/internal/repository/user/human_mfa_otp.go index f0f3762c81..93706d714e 100644 --- a/internal/repository/user/human_mfa_otp.go +++ b/internal/repository/user/human_mfa_otp.go @@ -7,6 +7,7 @@ import ( "github.com/zitadel/zitadel/internal/api/http" "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/notification/senders" "github.com/zitadel/zitadel/internal/zerrors" ) @@ -280,6 +281,7 @@ type HumanOTPSMSCodeAddedEvent struct { Code *crypto.CryptoValue `json:"code,omitempty"` Expiry time.Duration `json:"expiry,omitempty"` TriggeredAtOrigin string `json:"triggerOrigin,omitempty"` + GeneratorID string `json:"generatorId,omitempty"` *AuthRequestInfo } @@ -305,6 +307,7 @@ func NewHumanOTPSMSCodeAddedEvent( code *crypto.CryptoValue, expiry time.Duration, info *AuthRequestInfo, + generatorID string, ) *HumanOTPSMSCodeAddedEvent { return &HumanOTPSMSCodeAddedEvent{ BaseEvent: *eventstore.NewBaseEventForPush( @@ -316,15 +319,14 @@ func NewHumanOTPSMSCodeAddedEvent( Expiry: expiry, TriggeredAtOrigin: http.DomainContext(ctx).Origin(), AuthRequestInfo: info, + GeneratorID: generatorID, } } type HumanOTPSMSCodeSentEvent struct { eventstore.BaseEvent `json:"-"` - Code *crypto.CryptoValue `json:"code,omitempty"` - Expiry time.Duration `json:"expiry,omitempty"` - *AuthRequestInfo + GeneratorInfo *senders.CodeGeneratorInfo `json:"generatorInfo,omitempty"` } func (e *HumanOTPSMSCodeSentEvent) Payload() interface{} { @@ -342,6 +344,7 @@ func (e *HumanOTPSMSCodeSentEvent) SetBaseEvent(event *eventstore.BaseEvent) { func NewHumanOTPSMSCodeSentEvent( ctx context.Context, aggregate *eventstore.Aggregate, + generatorInfo *senders.CodeGeneratorInfo, ) *HumanOTPSMSCodeSentEvent { return &HumanOTPSMSCodeSentEvent{ BaseEvent: *eventstore.NewBaseEventForPush( @@ -349,6 +352,7 @@ func NewHumanOTPSMSCodeSentEvent( aggregate, HumanOTPSMSCodeSentType, ), + GeneratorInfo: generatorInfo, } } diff --git a/internal/repository/user/human_password.go b/internal/repository/user/human_password.go index c425c144b2..4251a7987f 100644 --- a/internal/repository/user/human_password.go +++ b/internal/repository/user/human_password.go @@ -8,6 +8,7 @@ import ( "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/notification/senders" "github.com/zitadel/zitadel/internal/zerrors" ) @@ -89,6 +90,7 @@ type HumanPasswordCodeAddedEvent struct { TriggeredAtOrigin string `json:"triggerOrigin,omitempty"` // AuthRequest is only used in V1 Login UI AuthRequestID string `json:"authRequestID,omitempty"` + GeneratorID string `json:"generatorId,omitempty"` } func (e *HumanPasswordCodeAddedEvent) Payload() interface{} { @@ -109,7 +111,8 @@ func NewHumanPasswordCodeAddedEvent( code *crypto.CryptoValue, expiry time.Duration, notificationType domain.NotificationType, - authRequestID string, + authRequestID, + generatorID string, ) *HumanPasswordCodeAddedEvent { return &HumanPasswordCodeAddedEvent{ BaseEvent: *eventstore.NewBaseEventForPush( @@ -122,6 +125,7 @@ func NewHumanPasswordCodeAddedEvent( NotificationType: notificationType, TriggeredAtOrigin: http.DomainContext(ctx).Origin(), AuthRequestID: authRequestID, + GeneratorID: generatorID, } } @@ -162,33 +166,34 @@ func HumanPasswordCodeAddedEventMapper(event eventstore.Event) (eventstore.Event } type HumanPasswordCodeSentEvent struct { - eventstore.BaseEvent `json:"-"` + *eventstore.BaseEvent `json:"-"` + + GeneratorInfo *senders.CodeGeneratorInfo `json:"generatorInfo,omitempty"` +} + +func (e *HumanPasswordCodeSentEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = event } func (e *HumanPasswordCodeSentEvent) Payload() interface{} { - return nil + return e } func (e *HumanPasswordCodeSentEvent) UniqueConstraints() []*eventstore.UniqueConstraint { return nil } -func NewHumanPasswordCodeSentEvent(ctx context.Context, aggregate *eventstore.Aggregate) *HumanPasswordCodeSentEvent { +func NewHumanPasswordCodeSentEvent(ctx context.Context, aggregate *eventstore.Aggregate, generatorInfo *senders.CodeGeneratorInfo) *HumanPasswordCodeSentEvent { return &HumanPasswordCodeSentEvent{ - BaseEvent: *eventstore.NewBaseEventForPush( + BaseEvent: eventstore.NewBaseEventForPush( ctx, aggregate, HumanPasswordCodeSentType, ), + GeneratorInfo: generatorInfo, } } -func HumanPasswordCodeSentEventMapper(event eventstore.Event) (eventstore.Event, error) { - return &HumanPasswordCodeSentEvent{ - BaseEvent: *eventstore.BaseEventFromRepo(event), - }, nil -} - type HumanPasswordChangeSentEvent struct { eventstore.BaseEvent `json:"-"` } diff --git a/internal/repository/user/human_phone.go b/internal/repository/user/human_phone.go index 5655b1b3d3..b86cbf93ef 100644 --- a/internal/repository/user/human_phone.go +++ b/internal/repository/user/human_phone.go @@ -8,6 +8,7 @@ import ( "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/notification/senders" "github.com/zitadel/zitadel/internal/zerrors" ) @@ -151,6 +152,7 @@ type HumanPhoneCodeAddedEvent struct { Code *crypto.CryptoValue `json:"code,omitempty"` Expiry time.Duration `json:"expiry,omitempty"` CodeReturned bool `json:"code_returned,omitempty"` + GeneratorID string `json:"generatorId,omitempty"` TriggeredAtOrigin string `json:"triggerOrigin,omitempty"` } @@ -171,15 +173,18 @@ func NewHumanPhoneCodeAddedEvent( aggregate *eventstore.Aggregate, code *crypto.CryptoValue, expiry time.Duration, + generatorID string, ) *HumanPhoneCodeAddedEvent { - return NewHumanPhoneCodeAddedEventV2(ctx, aggregate, code, expiry, false) + return NewHumanPhoneCodeAddedEventV2(ctx, aggregate, code, expiry, false, generatorID) } + func NewHumanPhoneCodeAddedEventV2( ctx context.Context, aggregate *eventstore.Aggregate, code *crypto.CryptoValue, expiry time.Duration, codeReturned bool, + generatorID string, ) *HumanPhoneCodeAddedEvent { return &HumanPhoneCodeAddedEvent{ BaseEvent: *eventstore.NewBaseEventForPush( @@ -190,6 +195,7 @@ func NewHumanPhoneCodeAddedEventV2( Code: code, Expiry: expiry, CodeReturned: codeReturned, + GeneratorID: generatorID, TriggeredAtOrigin: http.DomainContext(ctx).Origin(), } } @@ -207,7 +213,13 @@ func HumanPhoneCodeAddedEventMapper(event eventstore.Event) (eventstore.Event, e } type HumanPhoneCodeSentEvent struct { - eventstore.BaseEvent `json:"-"` + *eventstore.BaseEvent `json:"-"` + + GeneratorInfo *senders.CodeGeneratorInfo `json:"generatorInfo,omitempty"` +} + +func (e *HumanPhoneCodeSentEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = event } func (e *HumanPhoneCodeSentEvent) Payload() interface{} { @@ -218,18 +230,13 @@ func (e *HumanPhoneCodeSentEvent) UniqueConstraints() []*eventstore.UniqueConstr return nil } -func NewHumanPhoneCodeSentEvent(ctx context.Context, aggregate *eventstore.Aggregate) *HumanPhoneCodeSentEvent { +func NewHumanPhoneCodeSentEvent(ctx context.Context, aggregate *eventstore.Aggregate, generatorInfo *senders.CodeGeneratorInfo) *HumanPhoneCodeSentEvent { return &HumanPhoneCodeSentEvent{ - BaseEvent: *eventstore.NewBaseEventForPush( + BaseEvent: eventstore.NewBaseEventForPush( ctx, aggregate, HumanPhoneCodeSentType, ), + GeneratorInfo: generatorInfo, } } - -func HumanPhoneCodeSentEventMapper(event eventstore.Event) (eventstore.Event, error) { - return &HumanPhoneCodeSentEvent{ - BaseEvent: *eventstore.BaseEventFromRepo(event), - }, nil -} diff --git a/internal/repository/user/schemauser/phone.go b/internal/repository/user/schemauser/phone.go index a491dab776..9a68168198 100644 --- a/internal/repository/user/schemauser/phone.go +++ b/internal/repository/user/schemauser/phone.go @@ -8,6 +8,7 @@ import ( "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/notification/senders" ) const ( @@ -107,6 +108,7 @@ type PhoneCodeAddedEvent struct { Code *crypto.CryptoValue `json:"code,omitempty"` Expiry time.Duration `json:"expiry,omitempty"` CodeReturned bool `json:"code_returned,omitempty"` + GeneratorID string `json:"generatorId,omitempty"` TriggeredAtOrigin string `json:"triggerOrigin,omitempty"` } @@ -132,6 +134,7 @@ func NewPhoneCodeAddedEvent( code *crypto.CryptoValue, expiry time.Duration, codeReturned bool, + generatorID string, ) *PhoneCodeAddedEvent { return &PhoneCodeAddedEvent{ BaseEvent: eventstore.NewBaseEventForPush( @@ -142,12 +145,15 @@ func NewPhoneCodeAddedEvent( Code: code, Expiry: expiry, CodeReturned: codeReturned, + GeneratorID: generatorID, TriggeredAtOrigin: http.DomainContext(ctx).Origin(), } } type PhoneCodeSentEvent struct { *eventstore.BaseEvent `json:"-"` + + GeneratorInfo *senders.CodeGeneratorInfo `json:"generatorInfo,omitempty"` } func (e *PhoneCodeSentEvent) Payload() interface{} { @@ -162,12 +168,13 @@ func (e *PhoneCodeSentEvent) SetBaseEvent(event *eventstore.BaseEvent) { e.BaseEvent = event } -func NewPhoneCodeSentEvent(ctx context.Context, aggregate *eventstore.Aggregate) *PhoneCodeSentEvent { +func NewPhoneCodeSentEvent(ctx context.Context, aggregate *eventstore.Aggregate, generatorInfo *senders.CodeGeneratorInfo) *PhoneCodeSentEvent { return &PhoneCodeSentEvent{ BaseEvent: eventstore.NewBaseEventForPush( ctx, aggregate, PhoneCodeSentType, ), + GeneratorInfo: generatorInfo, } } diff --git a/internal/static/i18n/en.yaml b/internal/static/i18n/en.yaml index 4f4e06fd7f..110a8d71e0 100644 --- a/internal/static/i18n/en.yaml +++ b/internal/static/i18n/en.yaml @@ -53,6 +53,7 @@ Errors: NotFound: SMS configuration not found AlreadyActive: SMS configuration already active AlreadyDeactivated: SMS configuration already deactivated + NotExternalVerification: SMS configuration does not support code verification SMTP: NotEmailMessage: message is not EmailMessage RequiredAttributes: subject, recipients and content must be set but some or all of them are empty diff --git a/proto/zitadel/admin.proto b/proto/zitadel/admin.proto index 6e1020cf5e..d3cf774f41 100644 --- a/proto/zitadel/admin.proto +++ b/proto/zitadel/admin.proto @@ -5176,11 +5176,10 @@ message AddSMSProviderTwilioRequest { } ]; string sender_number = 3 [ - (validate.rules).string = {min_len: 1, max_len: 200}, - (google.api.field_behavior) = REQUIRED, + (validate.rules).string = {min_len: 0, max_len: 200}, (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "\"AB123b9e61d238abae7d3be7b65ecbc987\""; - min_length: 1; + min_length: 0; max_length: 200; } ]; @@ -5192,6 +5191,14 @@ message AddSMSProviderTwilioRequest { max_length: 200; } ]; + string verify_service_sid = 5 [ + (validate.rules).string = {min_len: 0, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"AB123b9e61d238abae7d3be7b65ecbc987\""; + min_length: 0; + max_length: 200; + } + ]; } message AddSMSProviderTwilioResponse { @@ -5211,8 +5218,7 @@ message UpdateSMSProviderTwilioRequest { } ]; string sender_number = 3 [ - (validate.rules).string = {min_len: 1, max_len: 200}, - (google.api.field_behavior) = REQUIRED, + (validate.rules).string = {min_len: 0, max_len: 200}, (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "\"AB123b9e61d238abae7d3be7b65ecbc987\""; min_length: 1; @@ -5227,6 +5233,14 @@ message UpdateSMSProviderTwilioRequest { max_length: 200; } ]; + string verify_service_sid = 5 [ + (validate.rules).string = {min_len: 0, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"AB123b9e61d238abae7d3be7b65ecbc987\""; + min_length: 0; + max_length: 200; + } + ]; } message UpdateSMSProviderTwilioResponse { diff --git a/proto/zitadel/settings.proto b/proto/zitadel/settings.proto index a2a6806c65..c761e1c841 100644 --- a/proto/zitadel/settings.proto +++ b/proto/zitadel/settings.proto @@ -161,6 +161,7 @@ message SMSProvider { message TwilioConfig { string sid = 1; string sender_number = 2; + string verify_service_sid = 3; } message HTTPConfig { From 7247f62006a8bdd6fef7b5fcae1b2a193db9d00c Mon Sep 17 00:00:00 2001 From: Mostafa Galal <77402549+MostafaGalal1@users.noreply.github.com> Date: Thu, 26 Sep 2024 15:54:36 +0300 Subject: [PATCH 18/19] docs(guides): Fixing incorrect placement of hyphen in overview (#8681) # Which Problems Are Solved - There was an incorrect placement of a hyphen in a sentence. # How the Problems Are Solved - Corrected by replacing the hyphen with a comma and adding a verb (ready to go, offering a) Co-authored-by: Fabi --- docs/docs/guides/overview.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/docs/guides/overview.mdx b/docs/docs/guides/overview.mdx index d01a4a6903..7fd7c2aa70 100644 --- a/docs/docs/guides/overview.mdx +++ b/docs/docs/guides/overview.mdx @@ -23,7 +23,7 @@ If you're unsure, consider the generous free tier of [ZITADEL Cloud](./manage/cl Choose [ZITADEL Cloud](./manage/cloud/overview) if you want: -- A turnkey solution that's ready to go- A generous free tier with an excellent pay-as-you-go option +- A turnkey solution that's ready to go, offering a generous free tier with an excellent pay-as-you-go option. - Global scalability without the hassle of managing it yourself - Data-residency compliance for your customers @@ -47,4 +47,4 @@ ZITADEL holds bi-weekly community calls. To join the community calls use [this l ZITADEL is open source — and so is the documentation. -If you find any inaccuracies, spelling mistakes, or unclear text passages, please don't hesitate to leave a comment or [contribute a corresponding change](https://github.com/zitadel/zitadel/blob/main/CONTRIBUTING.md). \ No newline at end of file +If you find any inaccuracies, spelling mistakes, or unclear text passages, please don't hesitate to leave a comment or [contribute a corresponding change](https://github.com/zitadel/zitadel/blob/main/CONTRIBUTING.md). From 63d733b3a2e3ceeec4c27f71836695c6ff6a82ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=B6hlmann?= Date: Thu, 26 Sep 2024 15:55:41 +0200 Subject: [PATCH 19/19] perf(oidc): disable push of user token meta-event (#8691) # Which Problems Are Solved When executing many concurrent authentication requests on a single machine user, there were performance issues. As the same aggregate is being searched and written to concurrently, we traced it down to a locking issue on the used index. We already optimized the token endpoint by creating a separate OIDC aggregate. At the time we decided to push a single event to the user aggregate, for the user audit log. See [technical advisory 10010](https://zitadel.com/docs/support/advisory/a10010) for more details. However, a recent security fix introduced an additional search query on the user aggregate, causing the locking issue we found. # How the Problems Are Solved Add a feature flag which disables pushing of the `user.token.v2.added`. The event has no importance and was only added for informational purposes on the user objects. The `oidc_session.access_token.added` is the actual payload event and is pushed on the OIDC session aggregate and can still be used for audit trail. # Additional Changes - Fix an event mapper type for `SystemOIDCSingleV1SessionTerminationEventType` # Additional Context - Reported by support request - https://github.com/zitadel/zitadel/pull/7822 changed the token aggregate - https://github.com/zitadel/zitadel/pull/8631 introduced user state check Load test trace graph with `user.token.v2.added` **enabled**. Query times are steadily increasing: ![image](https://github.com/user-attachments/assets/4aa25055-8721-4e93-b695-625560979909) Load test trace graph with `user.token.v2.added` **disabled**. Query times constant: ![image](https://github.com/user-attachments/assets/a7657f6c-0c55-401b-8291-453da5d5caf9) --------- Co-authored-by: Livio Spring --- internal/api/grpc/feature/v2/converter.go | 4 + .../api/grpc/feature/v2/converter_test.go | 8 + internal/command/instance_features.go | 4 +- internal/command/instance_features_model.go | 5 + internal/command/oidc_session.go | 8 +- internal/command/oidc_session_test.go | 239 ++++++++++++++++++ internal/command/system_features.go | 4 +- internal/command/system_features_model.go | 5 + internal/feature/feature.go | 2 + internal/feature/key_enumer.go | 18 +- internal/query/instance_features.go | 1 + internal/query/instance_features_model.go | 4 + .../query/projection/instance_features.go | 4 + internal/query/projection/system_features.go | 4 + internal/query/system_features.go | 1 + internal/query/system_features_model.go | 3 + .../feature/feature_v2/eventstore.go | 4 +- .../repository/feature/feature_v2/feature.go | 2 + proto/zitadel/feature/v2/instance.proto | 14 + proto/zitadel/feature/v2/system.proto | 14 + 20 files changed, 334 insertions(+), 14 deletions(-) diff --git a/internal/api/grpc/feature/v2/converter.go b/internal/api/grpc/feature/v2/converter.go index 5817b47c44..e8b57a2885 100644 --- a/internal/api/grpc/feature/v2/converter.go +++ b/internal/api/grpc/feature/v2/converter.go @@ -18,6 +18,7 @@ func systemFeaturesToCommand(req *feature_pb.SetSystemFeaturesRequest) *command. TokenExchange: req.OidcTokenExchange, ImprovedPerformance: improvedPerformanceListToDomain(req.ImprovedPerformance), OIDCSingleV1SessionTermination: req.OidcSingleV1SessionTermination, + DisableUserTokenEvent: req.DisableUserTokenEvent, } } @@ -32,6 +33,7 @@ func systemFeaturesToPb(f *query.SystemFeatures) *feature_pb.GetSystemFeaturesRe Actions: featureSourceToFlagPb(&f.Actions), ImprovedPerformance: featureSourceToImprovedPerformanceFlagPb(&f.ImprovedPerformance), OidcSingleV1SessionTermination: featureSourceToFlagPb(&f.OIDCSingleV1SessionTermination), + DisableUserTokenEvent: featureSourceToFlagPb(&f.DisableUserTokenEvent), } } @@ -47,6 +49,7 @@ func instanceFeaturesToCommand(req *feature_pb.SetInstanceFeaturesRequest) *comm WebKey: req.WebKey, DebugOIDCParentError: req.DebugOidcParentError, OIDCSingleV1SessionTermination: req.OidcSingleV1SessionTermination, + DisableUserTokenEvent: req.DisableUserTokenEvent, } } @@ -63,6 +66,7 @@ func instanceFeaturesToPb(f *query.InstanceFeatures) *feature_pb.GetInstanceFeat WebKey: featureSourceToFlagPb(&f.WebKey), DebugOidcParentError: featureSourceToFlagPb(&f.DebugOIDCParentError), OidcSingleV1SessionTermination: featureSourceToFlagPb(&f.OIDCSingleV1SessionTermination), + DisableUserTokenEvent: featureSourceToFlagPb(&f.DisableUserTokenEvent), } } diff --git a/internal/api/grpc/feature/v2/converter_test.go b/internal/api/grpc/feature/v2/converter_test.go index b0b21c3c24..79bfa34839 100644 --- a/internal/api/grpc/feature/v2/converter_test.go +++ b/internal/api/grpc/feature/v2/converter_test.go @@ -119,6 +119,10 @@ func Test_systemFeaturesToPb(t *testing.T) { Enabled: true, Source: feature_pb.Source_SOURCE_SYSTEM, }, + DisableUserTokenEvent: &feature_pb.FeatureFlag{ + Enabled: false, + Source: feature_pb.Source_SOURCE_UNSPECIFIED, + }, } got := systemFeaturesToPb(arg) assert.Equal(t, want, got) @@ -243,6 +247,10 @@ func Test_instanceFeaturesToPb(t *testing.T) { Enabled: true, Source: feature_pb.Source_SOURCE_INSTANCE, }, + DisableUserTokenEvent: &feature_pb.FeatureFlag{ + Enabled: false, + Source: feature_pb.Source_SOURCE_UNSPECIFIED, + }, } got := instanceFeaturesToPb(arg) assert.Equal(t, want, got) diff --git a/internal/command/instance_features.go b/internal/command/instance_features.go index 9517c148b6..79d3d25ffe 100644 --- a/internal/command/instance_features.go +++ b/internal/command/instance_features.go @@ -26,6 +26,7 @@ type InstanceFeatures struct { WebKey *bool DebugOIDCParentError *bool OIDCSingleV1SessionTermination *bool + DisableUserTokenEvent *bool } func (m *InstanceFeatures) isEmpty() bool { @@ -39,7 +40,8 @@ func (m *InstanceFeatures) isEmpty() bool { m.ImprovedPerformance == nil && m.WebKey == nil && m.DebugOIDCParentError == nil && - m.OIDCSingleV1SessionTermination == nil + m.OIDCSingleV1SessionTermination == nil && + m.DisableUserTokenEvent == nil } func (c *Commands) SetInstanceFeatures(ctx context.Context, f *InstanceFeatures) (*domain.ObjectDetails, error) { diff --git a/internal/command/instance_features_model.go b/internal/command/instance_features_model.go index 218b62864d..5ed0b9c24b 100644 --- a/internal/command/instance_features_model.go +++ b/internal/command/instance_features_model.go @@ -70,6 +70,7 @@ func (m *InstanceFeaturesWriteModel) Query() *eventstore.SearchQueryBuilder { feature_v2.InstanceWebKeyEventType, feature_v2.InstanceDebugOIDCParentErrorEventType, feature_v2.InstanceOIDCSingleV1SessionTerminationEventType, + feature_v2.InstanceDisableUserTokenEvent, ). Builder().ResourceOwner(m.ResourceOwner) } @@ -112,6 +113,9 @@ func reduceInstanceFeature(features *InstanceFeatures, key feature.Key, value an case feature.KeyOIDCSingleV1SessionTermination: v := value.(bool) features.OIDCSingleV1SessionTermination = &v + case feature.KeyDisableUserTokenEvent: + v := value.(bool) + features.DisableUserTokenEvent = &v } } @@ -128,5 +132,6 @@ func (wm *InstanceFeaturesWriteModel) setCommands(ctx context.Context, f *Instan cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.WebKey, f.WebKey, feature_v2.InstanceWebKeyEventType) cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.DebugOIDCParentError, f.DebugOIDCParentError, feature_v2.InstanceDebugOIDCParentErrorEventType) cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.OIDCSingleV1SessionTermination, f.OIDCSingleV1SessionTermination, feature_v2.InstanceOIDCSingleV1SessionTerminationEventType) + cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.DisableUserTokenEvent, f.DisableUserTokenEvent, feature_v2.InstanceDisableUserTokenEvent) return cmds } diff --git a/internal/command/oidc_session.go b/internal/command/oidc_session.go index 1ad46ba7d6..95a5934b91 100644 --- a/internal/command/oidc_session.go +++ b/internal/command/oidc_session.go @@ -423,10 +423,10 @@ func (c *OIDCSessionEvents) AddAccessToken(ctx context.Context, scope []string, return err } c.accessTokenID = AccessTokenPrefix + accessTokenID - c.events = append(c.events, - oidcsession.NewAccessTokenAddedEvent(ctx, c.oidcSessionWriteModel.aggregate, c.accessTokenID, scope, c.accessTokenLifetime, reason, actor), - user.NewUserTokenV2AddedEvent(ctx, &user.NewAggregate(userID, resourceOwner).Aggregate, c.accessTokenID), // for user audit log - ) + c.events = append(c.events, oidcsession.NewAccessTokenAddedEvent(ctx, c.oidcSessionWriteModel.aggregate, c.accessTokenID, scope, c.accessTokenLifetime, reason, actor)) + if !authz.GetFeatures(ctx).DisableUserTokenEvent { + c.events = append(c.events, user.NewUserTokenV2AddedEvent(ctx, &user.NewAggregate(userID, resourceOwner).Aggregate, c.accessTokenID)) + } return nil } diff --git a/internal/command/oidc_session_test.go b/internal/command/oidc_session_test.go index 6d9ee6e32e..4df173c7d5 100644 --- a/internal/command/oidc_session_test.go +++ b/internal/command/oidc_session_test.go @@ -18,6 +18,7 @@ import ( "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/feature" "github.com/zitadel/zitadel/internal/id" "github.com/zitadel/zitadel/internal/id/mock" "github.com/zitadel/zitadel/internal/repository/authrequest" @@ -436,6 +437,144 @@ func TestCommands_CreateOIDCSessionFromAuthRequest(t *testing.T) { state: "state", }, }, + { + "disable user token event", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + authrequest.NewAddedEvent(context.Background(), &authrequest.NewAggregate("V2_authRequestID", "instanceID").Aggregate, + "loginClient", + "clientID", + "redirectURI", + "state", + "nonce", + []string{"openid", "offline_access"}, + []string{"audience"}, + domain.OIDCResponseTypeCode, + domain.OIDCResponseModeQuery, + &domain.OIDCCodeChallenge{ + Challenge: "challenge", + Method: domain.CodeChallengeMethodS256, + }, + []domain.Prompt{domain.PromptNone}, + []string{"en", "de"}, + gu.Ptr(time.Duration(0)), + gu.Ptr("loginHint"), + gu.Ptr("hintUserID"), + true, + ), + ), + eventFromEventPusher( + authrequest.NewCodeAddedEvent(context.Background(), &authrequest.NewAggregate("V2_authRequestID", "instanceID").Aggregate), + ), + eventFromEventPusher( + authrequest.NewSessionLinkedEvent(context.Background(), &authrequest.NewAggregate("V2_authRequestID", "instanceID").Aggregate, + "sessionID", + "userID", + testNow, + []domain.UserAuthMethodType{domain.UserAuthMethodTypePassword}, + ), + ), + ), + expectFilter( + eventFromEventPusher( + session.NewAddedEvent(context.Background(), + &session.NewAggregate("sessionID", "instance1").Aggregate, + &domain.UserAgent{ + FingerprintID: gu.Ptr("fp1"), + IP: net.ParseIP("1.2.3.4"), + Description: gu.Ptr("firefox"), + Header: http.Header{"foo": []string{"bar"}}, + }, + ), + ), + eventFromEventPusher( + session.NewUserCheckedEvent(context.Background(), &session.NewAggregate("sessionID", "instanceID").Aggregate, + "userID", "org1", testNow, &language.Afrikaans), + ), + eventFromEventPusher( + session.NewPasswordCheckedEvent(context.Background(), &session.NewAggregate("sessionID", "instanceID").Aggregate, + testNow), + ), + ), + expectFilter( + user.NewHumanAddedEvent( + context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.Afrikaans, + domain.GenderUnspecified, + "email", + false, + ), + ), + expectFilter(), // token lifetime + expectPush( + authrequest.NewCodeExchangedEvent(context.Background(), &authrequest.NewAggregate("V2_authRequestID", "instanceID").Aggregate), + oidcsession.NewAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "org1").Aggregate, + "userID", "org1", "sessionID", "clientID", []string{"audience"}, []string{"openid", "offline_access"}, + []domain.UserAuthMethodType{domain.UserAuthMethodTypePassword}, testNow, "nonce", &language.Afrikaans, + &domain.UserAgent{ + FingerprintID: gu.Ptr("fp1"), + IP: net.ParseIP("1.2.3.4"), + Description: gu.Ptr("firefox"), + Header: http.Header{"foo": []string{"bar"}}, + }, + ), + oidcsession.NewAccessTokenAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "org1").Aggregate, + "at_accessTokenID", []string{"openid", "offline_access"}, time.Hour, domain.TokenReasonAuthRequest, nil), + oidcsession.NewRefreshTokenAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "org1").Aggregate, + "rt_refreshTokenID", 7*24*time.Hour, 24*time.Hour), + authrequest.NewSucceededEvent(context.Background(), &authrequest.NewAggregate("V2_authRequestID", "instanceID").Aggregate), + ), + ), + idGenerator: mock.NewIDGeneratorExpectIDs(t, "oidcSessionID", "accessTokenID", "refreshTokenID"), + defaultAccessTokenLifetime: time.Hour, + defaultRefreshTokenLifetime: 7 * 24 * time.Hour, + defaultRefreshTokenIdleLifetime: 24 * time.Hour, + keyAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + }, + args{ + ctx: authz.WithFeatures( + authz.WithInstanceID(context.Background(), "instanceID"), + feature.Features{ + DisableUserTokenEvent: true, + }, + ), + authRequestID: "V2_authRequestID", + complianceCheck: mockAuthRequestComplianceChecker(nil), + needRefreshToken: true, + }, + res{ + session: &OIDCSession{ + SessionID: "sessionID", + TokenID: "V2_oidcSessionID-at_accessTokenID", + ClientID: "clientID", + UserID: "userID", + Audience: []string{"audience"}, + Expiration: time.Time{}.Add(time.Hour), + Scope: []string{"openid", "offline_access"}, + AuthMethods: []domain.UserAuthMethodType{domain.UserAuthMethodTypePassword}, + AuthTime: testNow, + Nonce: "nonce", + PreferredLanguage: &language.Afrikaans, + UserAgent: &domain.UserAgent{ + FingerprintID: gu.Ptr("fp1"), + IP: net.ParseIP("1.2.3.4"), + Description: gu.Ptr("firefox"), + Header: http.Header{"foo": []string{"bar"}}, + }, + Reason: domain.TokenReasonAuthRequest, + RefreshToken: "VjJfb2lkY1Nlc3Npb25JRC1ydF9yZWZyZXNoVG9rZW5JRDp1c2VySUQ", //V2_oidcSessionID-rt_refreshTokenID:userID + }, + state: "state", + }, + }, { "without ID token only (implicit)", fields{ @@ -800,6 +939,106 @@ func TestCommands_CreateOIDCSession(t *testing.T) { }, }, }, + { + name: "disable user token event", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + user.NewHumanAddedEvent( + context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.Afrikaans, + domain.GenderUnspecified, + "email", + false, + ), + ), + expectFilter(), // token lifetime + expectPush( + oidcsession.NewAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "org1").Aggregate, + "userID", "org1", "", "clientID", []string{"audience"}, []string{"openid", "offline_access"}, + []domain.UserAuthMethodType{domain.UserAuthMethodTypePassword}, testNow, "nonce", &language.Afrikaans, + &domain.UserAgent{ + FingerprintID: gu.Ptr("fp1"), + IP: net.ParseIP("1.2.3.4"), + Description: gu.Ptr("firefox"), + Header: http.Header{"foo": []string{"bar"}}, + }, + ), + oidcsession.NewAccessTokenAddedEvent(context.Background(), + &oidcsession.NewAggregate("V2_oidcSessionID", "org1").Aggregate, + "at_accessTokenID", []string{"openid", "offline_access"}, time.Hour, domain.TokenReasonAuthRequest, + &domain.TokenActor{ + UserID: "user2", + Issuer: "foo.com", + }, + ), + ), + ), + idGenerator: mock.NewIDGeneratorExpectIDs(t, "oidcSessionID", "accessTokenID"), + defaultAccessTokenLifetime: time.Hour, + defaultRefreshTokenLifetime: 7 * 24 * time.Hour, + defaultRefreshTokenIdleLifetime: 24 * time.Hour, + keyAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + }, + args: args{ + ctx: authz.WithFeatures( + authz.WithInstanceID(context.Background(), "instanceID"), + feature.Features{ + DisableUserTokenEvent: true, + }, + ), + userID: "userID", + resourceOwner: "org1", + clientID: "clientID", + audience: []string{"audience"}, + scope: []string{"openid", "offline_access"}, + authMethods: []domain.UserAuthMethodType{domain.UserAuthMethodTypePassword}, + authTime: testNow, + nonce: "nonce", + preferredLanguage: &language.Afrikaans, + userAgent: &domain.UserAgent{ + FingerprintID: gu.Ptr("fp1"), + IP: net.ParseIP("1.2.3.4"), + Description: gu.Ptr("firefox"), + Header: http.Header{"foo": []string{"bar"}}, + }, + reason: domain.TokenReasonAuthRequest, + actor: &domain.TokenActor{ + UserID: "user2", + Issuer: "foo.com", + }, + needRefreshToken: false, + }, + want: &OIDCSession{ + TokenID: "V2_oidcSessionID-at_accessTokenID", + ClientID: "clientID", + UserID: "userID", + Audience: []string{"audience"}, + Expiration: time.Time{}.Add(time.Hour), + Scope: []string{"openid", "offline_access"}, + AuthMethods: []domain.UserAuthMethodType{domain.UserAuthMethodTypePassword}, + AuthTime: testNow, + Nonce: "nonce", + PreferredLanguage: &language.Afrikaans, + UserAgent: &domain.UserAgent{ + FingerprintID: gu.Ptr("fp1"), + IP: net.ParseIP("1.2.3.4"), + Description: gu.Ptr("firefox"), + Header: http.Header{"foo": []string{"bar"}}, + }, + Reason: domain.TokenReasonAuthRequest, + Actor: &domain.TokenActor{ + UserID: "user2", + Issuer: "foo.com", + }, + }, + }, { name: "with refresh token", fields: fields{ diff --git a/internal/command/system_features.go b/internal/command/system_features.go index 1dcb3765a6..e024a6dd18 100644 --- a/internal/command/system_features.go +++ b/internal/command/system_features.go @@ -18,6 +18,7 @@ type SystemFeatures struct { Actions *bool ImprovedPerformance []feature.ImprovedPerformanceType OIDCSingleV1SessionTermination *bool + DisableUserTokenEvent *bool } func (m *SystemFeatures) isEmpty() bool { @@ -29,7 +30,8 @@ func (m *SystemFeatures) isEmpty() bool { m.Actions == nil && // nil check to allow unset improvements m.ImprovedPerformance == nil && - m.OIDCSingleV1SessionTermination == nil + m.OIDCSingleV1SessionTermination == nil && + m.DisableUserTokenEvent == nil } func (c *Commands) SetSystemFeatures(ctx context.Context, f *SystemFeatures) (*domain.ObjectDetails, error) { diff --git a/internal/command/system_features_model.go b/internal/command/system_features_model.go index 4c169ec69e..5cc70338bb 100644 --- a/internal/command/system_features_model.go +++ b/internal/command/system_features_model.go @@ -61,6 +61,7 @@ func (m *SystemFeaturesWriteModel) Query() *eventstore.SearchQueryBuilder { feature_v2.SystemActionsEventType, feature_v2.SystemImprovedPerformanceEventType, feature_v2.SystemOIDCSingleV1SessionTerminationEventType, + feature_v2.SystemDisableUserTokenEvent, ). Builder().ResourceOwner(m.ResourceOwner) } @@ -96,6 +97,9 @@ func reduceSystemFeature(features *SystemFeatures, key feature.Key, value any) { case feature.KeyOIDCSingleV1SessionTermination: v := value.(bool) features.OIDCSingleV1SessionTermination = &v + case feature.KeyDisableUserTokenEvent: + v := value.(bool) + features.DisableUserTokenEvent = &v } } @@ -110,6 +114,7 @@ func (wm *SystemFeaturesWriteModel) setCommands(ctx context.Context, f *SystemFe cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.Actions, f.Actions, feature_v2.SystemActionsEventType) cmds = appendFeatureSliceUpdate(ctx, cmds, aggregate, wm.ImprovedPerformance, f.ImprovedPerformance, feature_v2.SystemImprovedPerformanceEventType) cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.OIDCSingleV1SessionTermination, f.OIDCSingleV1SessionTermination, feature_v2.SystemOIDCSingleV1SessionTerminationEventType) + cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.DisableUserTokenEvent, f.DisableUserTokenEvent, feature_v2.SystemDisableUserTokenEvent) return cmds } diff --git a/internal/feature/feature.go b/internal/feature/feature.go index de7cdd8027..3104f6ed59 100644 --- a/internal/feature/feature.go +++ b/internal/feature/feature.go @@ -17,6 +17,7 @@ const ( KeyWebKey KeyDebugOIDCParentError KeyOIDCSingleV1SessionTermination + KeyDisableUserTokenEvent ) //go:generate enumer -type Level -transform snake -trimprefix Level @@ -43,6 +44,7 @@ type Features struct { WebKey bool `json:"web_key,omitempty"` DebugOIDCParentError bool `json:"debug_oidc_parent_error,omitempty"` OIDCSingleV1SessionTermination bool `json:"terminate_single_v1_session,omitempty"` + DisableUserTokenEvent bool `json:"disable_user_token_event,omitempty"` } type ImprovedPerformanceType int32 diff --git a/internal/feature/key_enumer.go b/internal/feature/key_enumer.go index cbe3c5bf7a..46d8613fbc 100644 --- a/internal/feature/key_enumer.go +++ b/internal/feature/key_enumer.go @@ -7,11 +7,11 @@ import ( "strings" ) -const _KeyName = "unspecifiedlogin_default_orgtrigger_introspection_projectionslegacy_introspectionuser_schematoken_exchangeactionsimproved_performanceweb_keydebug_oidc_parent_errorterminate_single_v1_session" +const _KeyName = "unspecifiedlogin_default_orgtrigger_introspection_projectionslegacy_introspectionuser_schematoken_exchangeactionsimproved_performanceweb_keydebug_oidc_parent_erroroidc_single_v1_session_terminationdisable_user_token_event" -var _KeyIndex = [...]uint8{0, 11, 28, 61, 81, 92, 106, 113, 133, 140, 163, 190} +var _KeyIndex = [...]uint8{0, 11, 28, 61, 81, 92, 106, 113, 133, 140, 163, 197, 221} -const _KeyLowerName = "unspecifiedlogin_default_orgtrigger_introspection_projectionslegacy_introspectionuser_schematoken_exchangeactionsimproved_performanceweb_keydebug_oidc_parent_errorterminate_single_v1_session" +const _KeyLowerName = "unspecifiedlogin_default_orgtrigger_introspection_projectionslegacy_introspectionuser_schematoken_exchangeactionsimproved_performanceweb_keydebug_oidc_parent_erroroidc_single_v1_session_terminationdisable_user_token_event" func (i Key) String() string { if i < 0 || i >= Key(len(_KeyIndex)-1) { @@ -35,9 +35,10 @@ func _KeyNoOp() { _ = x[KeyWebKey-(8)] _ = x[KeyDebugOIDCParentError-(9)] _ = x[KeyOIDCSingleV1SessionTermination-(10)] + _ = x[KeyDisableUserTokenEvent-(11)] } -var _KeyValues = []Key{KeyUnspecified, KeyLoginDefaultOrg, KeyTriggerIntrospectionProjections, KeyLegacyIntrospection, KeyUserSchema, KeyTokenExchange, KeyActions, KeyImprovedPerformance, KeyWebKey, KeyDebugOIDCParentError, KeyOIDCSingleV1SessionTermination} +var _KeyValues = []Key{KeyUnspecified, KeyLoginDefaultOrg, KeyTriggerIntrospectionProjections, KeyLegacyIntrospection, KeyUserSchema, KeyTokenExchange, KeyActions, KeyImprovedPerformance, KeyWebKey, KeyDebugOIDCParentError, KeyOIDCSingleV1SessionTermination, KeyDisableUserTokenEvent} var _KeyNameToValueMap = map[string]Key{ _KeyName[0:11]: KeyUnspecified, @@ -60,8 +61,10 @@ var _KeyNameToValueMap = map[string]Key{ _KeyLowerName[133:140]: KeyWebKey, _KeyName[140:163]: KeyDebugOIDCParentError, _KeyLowerName[140:163]: KeyDebugOIDCParentError, - _KeyName[163:190]: KeyOIDCSingleV1SessionTermination, - _KeyLowerName[163:190]: KeyOIDCSingleV1SessionTermination, + _KeyName[163:197]: KeyOIDCSingleV1SessionTermination, + _KeyLowerName[163:197]: KeyOIDCSingleV1SessionTermination, + _KeyName[197:221]: KeyDisableUserTokenEvent, + _KeyLowerName[197:221]: KeyDisableUserTokenEvent, } var _KeyNames = []string{ @@ -75,7 +78,8 @@ var _KeyNames = []string{ _KeyName[113:133], _KeyName[133:140], _KeyName[140:163], - _KeyName[163:190], + _KeyName[163:197], + _KeyName[197:221], } // KeyString retrieves an enum value from the enum constants string name. diff --git a/internal/query/instance_features.go b/internal/query/instance_features.go index 5f501faea0..1616d9b366 100644 --- a/internal/query/instance_features.go +++ b/internal/query/instance_features.go @@ -19,6 +19,7 @@ type InstanceFeatures struct { WebKey FeatureSource[bool] DebugOIDCParentError FeatureSource[bool] OIDCSingleV1SessionTermination FeatureSource[bool] + DisableUserTokenEvent FeatureSource[bool] } func (q *Queries) GetInstanceFeatures(ctx context.Context, cascade bool) (_ *InstanceFeatures, err error) { diff --git a/internal/query/instance_features_model.go b/internal/query/instance_features_model.go index d1a0833192..80515b4773 100644 --- a/internal/query/instance_features_model.go +++ b/internal/query/instance_features_model.go @@ -70,6 +70,7 @@ func (m *InstanceFeaturesReadModel) Query() *eventstore.SearchQueryBuilder { feature_v2.InstanceWebKeyEventType, feature_v2.InstanceDebugOIDCParentErrorEventType, feature_v2.InstanceOIDCSingleV1SessionTerminationEventType, + feature_v2.InstanceDisableUserTokenEvent, ). Builder().ResourceOwner(m.ResourceOwner) } @@ -94,6 +95,7 @@ func (m *InstanceFeaturesReadModel) populateFromSystem() bool { m.instance.Actions = m.system.Actions m.instance.ImprovedPerformance = m.system.ImprovedPerformance m.instance.OIDCSingleV1SessionTermination = m.system.OIDCSingleV1SessionTermination + m.instance.DisableUserTokenEvent = m.system.DisableUserTokenEvent return true } @@ -125,6 +127,8 @@ func reduceInstanceFeatureSet[T any](features *InstanceFeatures, event *feature_ features.DebugOIDCParentError.set(level, event.Value) case feature.KeyOIDCSingleV1SessionTermination: features.OIDCSingleV1SessionTermination.set(level, event.Value) + case feature.KeyDisableUserTokenEvent: + features.DisableUserTokenEvent.set(level, event.Value) } return nil } diff --git a/internal/query/projection/instance_features.go b/internal/query/projection/instance_features.go index 3eb2073c0f..1b18e42e76 100644 --- a/internal/query/projection/instance_features.go +++ b/internal/query/projection/instance_features.go @@ -100,6 +100,10 @@ func (*instanceFeatureProjection) Reducers() []handler.AggregateReducer { Event: feature_v2.InstanceOIDCSingleV1SessionTerminationEventType, Reduce: reduceInstanceSetFeature[bool], }, + { + Event: feature_v2.InstanceDisableUserTokenEvent, + Reduce: reduceInstanceSetFeature[bool], + }, { Event: instance.InstanceRemovedEventType, Reduce: reduceInstanceRemovedHelper(InstanceDomainInstanceIDCol), diff --git a/internal/query/projection/system_features.go b/internal/query/projection/system_features.go index 158da7a616..cf3013e57c 100644 --- a/internal/query/projection/system_features.go +++ b/internal/query/projection/system_features.go @@ -80,6 +80,10 @@ func (*systemFeatureProjection) Reducers() []handler.AggregateReducer { Event: feature_v2.SystemImprovedPerformanceEventType, Reduce: reduceSystemSetFeature[[]feature.ImprovedPerformanceType], }, + { + Event: feature_v2.SystemDisableUserTokenEvent, + Reduce: reduceSystemSetFeature[bool], + }, }, }} } diff --git a/internal/query/system_features.go b/internal/query/system_features.go index 940cb8fece..ddbd0a08ea 100644 --- a/internal/query/system_features.go +++ b/internal/query/system_features.go @@ -28,6 +28,7 @@ type SystemFeatures struct { Actions FeatureSource[bool] ImprovedPerformance FeatureSource[[]feature.ImprovedPerformanceType] OIDCSingleV1SessionTermination FeatureSource[bool] + DisableUserTokenEvent FeatureSource[bool] } func (q *Queries) GetSystemFeatures(ctx context.Context) (_ *SystemFeatures, err error) { diff --git a/internal/query/system_features_model.go b/internal/query/system_features_model.go index efb18f43bb..f8670c87fe 100644 --- a/internal/query/system_features_model.go +++ b/internal/query/system_features_model.go @@ -58,6 +58,7 @@ func (m *SystemFeaturesReadModel) Query() *eventstore.SearchQueryBuilder { feature_v2.SystemActionsEventType, feature_v2.SystemImprovedPerformanceEventType, feature_v2.SystemOIDCSingleV1SessionTerminationEventType, + feature_v2.SystemDisableUserTokenEvent, ). Builder().ResourceOwner(m.ResourceOwner) } @@ -91,6 +92,8 @@ func reduceSystemFeatureSet[T any](features *SystemFeatures, event *feature_v2.S features.ImprovedPerformance.set(level, event.Value) case feature.KeyOIDCSingleV1SessionTermination: features.OIDCSingleV1SessionTermination.set(level, event.Value) + case feature.KeyDisableUserTokenEvent: + features.DisableUserTokenEvent.set(level, event.Value) } return nil } diff --git a/internal/repository/feature/feature_v2/eventstore.go b/internal/repository/feature/feature_v2/eventstore.go index 09988bb975..866d331db4 100644 --- a/internal/repository/feature/feature_v2/eventstore.go +++ b/internal/repository/feature/feature_v2/eventstore.go @@ -14,7 +14,8 @@ func init() { eventstore.RegisterFilterEventMapper(AggregateType, SystemTokenExchangeEventType, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, SystemActionsEventType, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, SystemImprovedPerformanceEventType, eventstore.GenericEventMapper[SetEvent[[]feature.ImprovedPerformanceType]]) - eventstore.RegisterFilterEventMapper(AggregateType, InstanceOIDCSingleV1SessionTerminationEventType, eventstore.GenericEventMapper[SetEvent[bool]]) + eventstore.RegisterFilterEventMapper(AggregateType, SystemOIDCSingleV1SessionTerminationEventType, eventstore.GenericEventMapper[SetEvent[bool]]) + eventstore.RegisterFilterEventMapper(AggregateType, SystemDisableUserTokenEvent, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, InstanceResetEventType, eventstore.GenericEventMapper[ResetEvent]) eventstore.RegisterFilterEventMapper(AggregateType, InstanceLoginDefaultOrgEventType, eventstore.GenericEventMapper[SetEvent[bool]]) @@ -27,4 +28,5 @@ func init() { eventstore.RegisterFilterEventMapper(AggregateType, InstanceWebKeyEventType, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, InstanceDebugOIDCParentErrorEventType, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, InstanceOIDCSingleV1SessionTerminationEventType, eventstore.GenericEventMapper[SetEvent[bool]]) + eventstore.RegisterFilterEventMapper(AggregateType, InstanceDisableUserTokenEvent, eventstore.GenericEventMapper[SetEvent[bool]]) } diff --git a/internal/repository/feature/feature_v2/feature.go b/internal/repository/feature/feature_v2/feature.go index b88fba1a3a..95f7e44360 100644 --- a/internal/repository/feature/feature_v2/feature.go +++ b/internal/repository/feature/feature_v2/feature.go @@ -20,6 +20,7 @@ var ( SystemActionsEventType = setEventTypeFromFeature(feature.LevelSystem, feature.KeyActions) SystemImprovedPerformanceEventType = setEventTypeFromFeature(feature.LevelSystem, feature.KeyImprovedPerformance) SystemOIDCSingleV1SessionTerminationEventType = setEventTypeFromFeature(feature.LevelSystem, feature.KeyOIDCSingleV1SessionTermination) + SystemDisableUserTokenEvent = setEventTypeFromFeature(feature.LevelSystem, feature.KeyDisableUserTokenEvent) InstanceResetEventType = resetEventTypeFromFeature(feature.LevelInstance) InstanceLoginDefaultOrgEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyLoginDefaultOrg) @@ -32,6 +33,7 @@ var ( InstanceWebKeyEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyWebKey) InstanceDebugOIDCParentErrorEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyDebugOIDCParentError) InstanceOIDCSingleV1SessionTerminationEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyOIDCSingleV1SessionTermination) + InstanceDisableUserTokenEvent = setEventTypeFromFeature(feature.LevelInstance, feature.KeyDisableUserTokenEvent) ) const ( diff --git a/proto/zitadel/feature/v2/instance.proto b/proto/zitadel/feature/v2/instance.proto index 4dc261f06b..ee41c313f2 100644 --- a/proto/zitadel/feature/v2/instance.proto +++ b/proto/zitadel/feature/v2/instance.proto @@ -79,6 +79,13 @@ message SetInstanceFeaturesRequest{ description: "If the flag is enabled, you'll be able to terminate a single session from the login UI by providing an id_token with a `sid` claim as id_token_hint on the end_session endpoint. Note that currently all sessions from the same user agent (browser) are terminated in the login UI. Sessions managed through the Session API already allow the termination of single sessions."; } ]; + + optional bool disable_user_token_event = 11 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "true"; + description: "Do not push user token meta-event user.token.v2.added to improve performance on many concurrent single (machine-)user logins"; + } + ]; } message SetInstanceFeaturesResponse { @@ -171,4 +178,11 @@ message GetInstanceFeaturesResponse { description: "If the flag is enabled, you'll be able to terminate a single session from the login UI by providing an id_token with a `sid` claim as id_token_hint on the end_session endpoint. Note that currently all sessions from the same user agent (browser) are terminated in the login UI. Sessions managed through the Session API already allow the termination of single sessions."; } ]; + + FeatureFlag disable_user_token_event = 12 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "true"; + description: "Do not push user token meta-event user.token.v2.added to improve performance on many concurrent single (machine-)user logins"; + } + ]; } diff --git a/proto/zitadel/feature/v2/system.proto b/proto/zitadel/feature/v2/system.proto index 29d8824da6..70ff3c6506 100644 --- a/proto/zitadel/feature/v2/system.proto +++ b/proto/zitadel/feature/v2/system.proto @@ -68,6 +68,13 @@ message SetSystemFeaturesRequest{ description: "If the flag is enabled, you'll be able to terminate a single session from the login UI by providing an id_token with a `sid` claim as id_token_hint on the end_session endpoint. Note that currently all sessions from the same user agent (browser) are terminated in the login UI. Sessions managed through the Session API already allow the termination of single sessions."; } ]; + + optional bool disable_user_token_event = 9 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "true"; + description: "Do not push user token meta-event user.token.v2.added to improve performance on many concurrent single (machine-)user logins"; + } + ]; } message SetSystemFeaturesResponse { @@ -139,4 +146,11 @@ message GetSystemFeaturesResponse { description: "If the flag is enabled, you'll be able to terminate a single session from the login UI by providing an id_token with a `sid` claim as id_token_hint on the end_session endpoint. Note that currently all sessions from the same user agent (browser) are terminated in the login UI. Sessions managed through the Session API already allow the termination of single sessions."; } ]; + + FeatureFlag disable_user_token_event = 10 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "true"; + description: "Do not push user token meta-event user.token.v2.added to improve performance on many concurrent single (machine-)user logins"; + } + ]; }