From eba602e0648705e39edb7273f254dff89feffd90 Mon Sep 17 00:00:00 2001 From: Livio Spring Date: Wed, 9 Nov 2022 09:33:50 +0100 Subject: [PATCH] feat: allow import of federated users in ImportHumanUser (#4675) Co-authored-by: Fabi <38692350+hifabienne@users.noreply.github.com> --- docs/docs/apis/proto/management.md | 14 + internal/api/grpc/admin/import.go | 4 +- internal/api/grpc/management/user.go | 4 +- .../api/grpc/management/user_converter.go | 12 +- internal/command/user_human.go | 20 +- internal/command/user_human_test.go | 284 +++++++++++++++++- proto/zitadel/management.proto | 10 +- 7 files changed, 332 insertions(+), 16 deletions(-) diff --git a/docs/docs/apis/proto/management.md b/docs/docs/apis/proto/management.md index 43eebe443e..77b950965b 100644 --- a/docs/docs/apis/proto/management.md +++ b/docs/docs/apis/proto/management.md @@ -5408,6 +5408,7 @@ This is an empty response | password_change_required | bool | - | | | request_passwordless_registration | bool | - | | | otp_code | string | - | | +| idps | repeated ImportHumanUserRequest.IDP | - | | @@ -5436,6 +5437,19 @@ This is an empty response +### ImportHumanUserRequest.IDP + + + +| Field | Type | Description | Validation | +| ----- | ---- | ----------- | ----------- | +| config_id | string | internal id of the IDP in ZITADEL | string.min_len: 1
string.max_len: 200
| +| external_user_id | string | id of the user on the IDP | string.min_len: 1
string.max_len: 200
| +| display_name | string | (display) name of the user on the IDP | string.max_len: 200
| + + + + ### ImportHumanUserRequest.Phone diff --git a/internal/api/grpc/admin/import.go b/internal/api/grpc/admin/import.go index b56d801288..b3f0ec4493 100644 --- a/internal/api/grpc/admin/import.go +++ b/internal/api/grpc/admin/import.go @@ -535,9 +535,9 @@ func (s *Server) importData(ctx context.Context, orgs []*admin_pb.DataOrg) (*adm if org.HumanUsers != nil { for _, user := range org.GetHumanUsers() { logging.Debugf("import user: %s", user.GetUserId()) - human, passwordless := management.ImportHumanUserRequestToDomain(user.User) + human, passwordless, links := management.ImportHumanUserRequestToDomain(user.User) human.AggregateID = user.UserId - _, _, err := s.command.ImportHuman(ctx, org.GetOrgId(), human, passwordless, initCodeGenerator, emailCodeGenerator, phoneCodeGenerator, passwordlessInitCode) + _, _, err := s.command.ImportHuman(ctx, org.GetOrgId(), human, passwordless, links, initCodeGenerator, emailCodeGenerator, phoneCodeGenerator, passwordlessInitCode) if err != nil { errors = append(errors, &admin_pb.ImportDataError{Type: "human_user", Id: user.GetUserId(), Message: err.Error()}) if isCtxTimeout(ctx) { diff --git a/internal/api/grpc/management/user.go b/internal/api/grpc/management/user.go index 2adb399b00..191bc82bce 100644 --- a/internal/api/grpc/management/user.go +++ b/internal/api/grpc/management/user.go @@ -230,7 +230,7 @@ func AddHumanUserRequestToAddHuman(req *mgmt_pb.AddHumanUserRequest) *command.Ad } func (s *Server) ImportHumanUser(ctx context.Context, req *mgmt_pb.ImportHumanUserRequest) (*mgmt_pb.ImportHumanUserResponse, error) { - human, passwordless := ImportHumanUserRequestToDomain(req) + human, passwordless, links := ImportHumanUserRequestToDomain(req) initCodeGenerator, err := s.query.InitEncryptionGenerator(ctx, domain.SecretGeneratorTypeInitCode, s.userCodeAlg) if err != nil { return nil, err @@ -247,7 +247,7 @@ func (s *Server) ImportHumanUser(ctx context.Context, req *mgmt_pb.ImportHumanUs if err != nil { return nil, err } - addedHuman, code, err := s.command.ImportHuman(ctx, authz.GetCtxData(ctx).OrgID, human, passwordless, initCodeGenerator, phoneCodeGenerator, emailCodeGenerator, passwordlessInitCode) + addedHuman, code, err := s.command.ImportHuman(ctx, authz.GetCtxData(ctx).OrgID, human, passwordless, links, initCodeGenerator, phoneCodeGenerator, emailCodeGenerator, passwordlessInitCode) if err != nil { return nil, err } diff --git a/internal/api/grpc/management/user_converter.go b/internal/api/grpc/management/user_converter.go index 638316d6b6..33d85bba93 100644 --- a/internal/api/grpc/management/user_converter.go +++ b/internal/api/grpc/management/user_converter.go @@ -120,7 +120,7 @@ func AddHumanUserRequestToDomain(req *mgmt_pb.AddHumanUserRequest) *domain.Human return h } -func ImportHumanUserRequestToDomain(req *mgmt_pb.ImportHumanUserRequest) (human *domain.Human, passwordless bool) { +func ImportHumanUserRequestToDomain(req *mgmt_pb.ImportHumanUserRequest) (human *domain.Human, passwordless bool, links []*domain.UserIDPLink) { human = &domain.Human{ Username: req.UserName, } @@ -153,8 +153,16 @@ func ImportHumanUserRequestToDomain(req *mgmt_pb.ImportHumanUserRequest) (human if req.HashedPassword != nil && req.HashedPassword.Value != "" && req.HashedPassword.Algorithm != "" { human.HashedPassword = domain.NewHashedPassword(req.HashedPassword.Value, req.HashedPassword.Algorithm) } + links = make([]*domain.UserIDPLink, len(req.Idps)) + for i, idp := range req.Idps { + links[i] = &domain.UserIDPLink{ + IDPConfigID: idp.ConfigId, + ExternalUserID: idp.ExternalUserId, + DisplayName: idp.DisplayName, + } + } - return human, req.RequestPasswordlessRegistration + return human, req.RequestPasswordlessRegistration, links } func AddMachineUserRequestToCommand(req *mgmt_pb.AddMachineUserRequest, resourceowner string) *command.Machine { diff --git a/internal/command/user_human.go b/internal/command/user_human.go index 4003a7cf98..0eeece11ce 100644 --- a/internal/command/user_human.go +++ b/internal/command/user_human.go @@ -285,7 +285,7 @@ func (h *AddHuman) shouldAddInitCode() bool { h.Password == "" } -func (c *Commands) ImportHuman(ctx context.Context, orgID string, human *domain.Human, passwordless bool, initCodeGenerator, emailCodeGenerator, phoneCodeGenerator, passwordlessCodeGenerator crypto.Generator) (_ *domain.Human, passwordlessCode *domain.PasswordlessInitCode, err error) { +func (c *Commands) ImportHuman(ctx context.Context, orgID string, human *domain.Human, passwordless bool, links []*domain.UserIDPLink, initCodeGenerator, emailCodeGenerator, phoneCodeGenerator, passwordlessCodeGenerator crypto.Generator) (_ *domain.Human, passwordlessCode *domain.PasswordlessInitCode, err error) { if orgID == "" { return nil, nil, errors.ThrowInvalidArgument(nil, "COMMAND-5N8fs", "Errors.ResourceOwnerMissing") } @@ -309,7 +309,7 @@ func (c *Commands) ImportHuman(ctx context.Context, orgID string, human *domain. } } - events, addedHuman, addedCode, code, err := c.importHuman(ctx, orgID, human, passwordless, domainPolicy, pwPolicy, initCodeGenerator, emailCodeGenerator, phoneCodeGenerator, passwordlessCodeGenerator) + events, addedHuman, addedCode, code, err := c.importHuman(ctx, orgID, human, passwordless, links, domainPolicy, pwPolicy, initCodeGenerator, emailCodeGenerator, phoneCodeGenerator, passwordlessCodeGenerator) if err != nil { return nil, nil, err } @@ -396,11 +396,11 @@ func (c *Commands) addHuman(ctx context.Context, orgID string, human *domain.Hum return c.createHuman(ctx, orgID, human, nil, false, false, domainPolicy, pwPolicy, initCodeGenerator, emailCodeGenerator, phoneCodeGenerator) } -func (c *Commands) importHuman(ctx context.Context, orgID string, human *domain.Human, passwordless bool, domainPolicy *domain.DomainPolicy, pwPolicy *domain.PasswordComplexityPolicy, initCodeGenerator, emailCodeGenerator, phoneCodeGenerator, passwordlessCodeGenerator crypto.Generator) (events []eventstore.Command, humanWriteModel *HumanWriteModel, passwordlessCodeWriteModel *HumanPasswordlessInitCodeWriteModel, code string, err error) { +func (c *Commands) importHuman(ctx context.Context, orgID string, human *domain.Human, passwordless bool, links []*domain.UserIDPLink, domainPolicy *domain.DomainPolicy, pwPolicy *domain.PasswordComplexityPolicy, initCodeGenerator, emailCodeGenerator, phoneCodeGenerator, passwordlessCodeGenerator crypto.Generator) (events []eventstore.Command, humanWriteModel *HumanWriteModel, passwordlessCodeWriteModel *HumanPasswordlessInitCodeWriteModel, code string, err error) { if orgID == "" || !human.IsValid() { return nil, nil, nil, "", errors.ThrowInvalidArgument(nil, "COMMAND-00p2b", "Errors.User.Invalid") } - events, humanWriteModel, err = c.createHuman(ctx, orgID, human, nil, false, passwordless, domainPolicy, pwPolicy, initCodeGenerator, emailCodeGenerator, phoneCodeGenerator) + events, humanWriteModel, err = c.createHuman(ctx, orgID, human, links, false, passwordless, domainPolicy, pwPolicy, initCodeGenerator, emailCodeGenerator, phoneCodeGenerator) if err != nil { return nil, nil, nil, "", err } @@ -428,10 +428,14 @@ func (c *Commands) registerHuman(ctx context.Context, orgID string, human *domai if human.Password != nil && human.Password.SecretString != "" { human.Password.ChangeRequired = false } - return c.createHuman(ctx, orgID, human, link, true, false, domainPolicy, pwPolicy, initCodeGenerator, emailCodeGenerator, phoneCodeGenerator) + var links []*domain.UserIDPLink + if link != nil { + links = append(links, link) + } + return c.createHuman(ctx, orgID, human, links, true, false, domainPolicy, pwPolicy, initCodeGenerator, emailCodeGenerator, phoneCodeGenerator) } -func (c *Commands) createHuman(ctx context.Context, orgID string, human *domain.Human, link *domain.UserIDPLink, selfregister, passwordless bool, domainPolicy *domain.DomainPolicy, pwPolicy *domain.PasswordComplexityPolicy, initCodeGenerator, emailCodeGenerator, phoneCodeGenerator crypto.Generator) (events []eventstore.Command, addedHuman *HumanWriteModel, err error) { +func (c *Commands) createHuman(ctx context.Context, orgID string, human *domain.Human, links []*domain.UserIDPLink, selfregister, passwordless bool, domainPolicy *domain.DomainPolicy, pwPolicy *domain.PasswordComplexityPolicy, initCodeGenerator, emailCodeGenerator, phoneCodeGenerator crypto.Generator) (events []eventstore.Command, addedHuman *HumanWriteModel, err error) { if err := human.CheckDomainPolicy(domainPolicy); err != nil { return nil, nil, err } @@ -476,7 +480,7 @@ func (c *Commands) createHuman(ctx context.Context, orgID string, human *domain. events = append(events, createAddHumanEvent(ctx, userAgg, human, domainPolicy.UserLoginMustBeDomain)) } - if link != nil { + for _, link := range links { event, err := c.addUserIDPLink(ctx, userAgg, link) if err != nil { return nil, nil, err @@ -484,7 +488,7 @@ func (c *Commands) createHuman(ctx context.Context, orgID string, human *domain. events = append(events, event) } - if human.IsInitialState(passwordless, link != nil) { + if human.IsInitialState(passwordless, len(links) > 0) { initCode, err := domain.NewInitUserCode(initCodeGenerator) if err != nil { return nil, nil, err diff --git a/internal/command/user_human_test.go b/internal/command/user_human_test.go index 9d0e7110d1..2dc0d66a4f 100644 --- a/internal/command/user_human_test.go +++ b/internal/command/user_human_test.go @@ -703,6 +703,7 @@ func TestCommandSide_ImportHuman(t *testing.T) { orgID string human *domain.Human passwordless bool + links []*domain.UserIDPLink secretGenerator crypto.Generator passwordlessInitCode crypto.Generator } @@ -1452,6 +1453,135 @@ func TestCommandSide_ImportHuman(t *testing.T) { }, }, }, + { + name: "add human (with idp), ok", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + org.NewDomainPolicyAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + true, + true, + true, + ), + ), + ), + expectFilter( + eventFromEventPusher( + org.NewPasswordComplexityPolicyAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + 1, + false, + false, + false, + false, + ), + ), + ), + expectFilter( + eventFromEventPusher( + org.NewIDPConfigAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + "idpID", + "name", + domain.IDPConfigTypeOIDC, + domain.IDPConfigStylingTypeUnspecified, + false, + ), + ), + eventFromEventPusher( + org.NewIDPOIDCConfigAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + "clientID", + "idpID", + "issuer", + "authEndpoint", + "tokenEndpoint", + nil, + domain.OIDCMappingFieldUnspecified, + domain.OIDCMappingFieldUnspecified, + ), + ), + eventFromEventPusher( + org.NewIdentityProviderAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + "idpID", + domain.IdentityProviderTypeOrg, + ), + ), + ), + expectPush( + []*repository.Event{ + eventFromEventPusher( + newAddHumanEvent("", false, ""), + ), + eventFromEventPusher( + user.NewUserIDPLinkAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "idpID", + "name", + "externalID", + ), + ), + eventFromEventPusher( + user.NewHumanEmailVerifiedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate), + ), + }, + uniqueConstraintsFromEventConstraint(user.NewAddUsernameUniqueConstraint("username", "org1", true)), + uniqueConstraintsFromEventConstraint(user.NewAddUserIDPLinkUniqueConstraint("idpID", "externalID")), + ), + ), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), + userPasswordAlg: crypto.CreateMockHashAlg(gomock.NewController(t)), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + human: &domain.Human{ + Username: "username", + Profile: &domain.Profile{ + FirstName: "firstname", + LastName: "lastname", + PreferredLanguage: language.English, + }, + Email: &domain.Email{ + EmailAddress: "email@test.ch", + IsEmailVerified: true, + }, + }, + links: []*domain.UserIDPLink{ + { + IDPConfigID: "idpID", + ExternalUserID: "externalID", + DisplayName: "name", + }, + }, + secretGenerator: GetMockSecretGenerator(t), + }, + res: res{ + wantHuman: &domain.Human{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "user1", + ResourceOwner: "org1", + }, + Username: "username", + Profile: &domain.Profile{ + FirstName: "firstname", + LastName: "lastname", + DisplayName: "firstname lastname", + PreferredLanguage: language.English, + }, + Email: &domain.Email{ + EmailAddress: "email@test.ch", + IsEmailVerified: true, + }, + State: domain.UserStateActive, + }, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -1460,7 +1590,7 @@ func TestCommandSide_ImportHuman(t *testing.T) { idGenerator: tt.fields.idGenerator, userPasswordAlg: tt.fields.userPasswordAlg, } - gotHuman, gotCode, err := r.ImportHuman(tt.args.ctx, tt.args.orgID, tt.args.human, tt.args.passwordless, tt.args.secretGenerator, tt.args.secretGenerator, tt.args.secretGenerator, tt.args.secretGenerator) + gotHuman, gotCode, err := r.ImportHuman(tt.args.ctx, tt.args.orgID, tt.args.human, tt.args.passwordless, tt.args.links, tt.args.secretGenerator, tt.args.secretGenerator, tt.args.secretGenerator, tt.args.secretGenerator) if tt.res.err == nil { assert.NoError(t, err) } @@ -2479,6 +2609,158 @@ func TestCommandSide_RegisterHuman(t *testing.T) { }, }, }, + { + name: "add with idp link, email verified, ok", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + org.NewDomainPolicyAddedEvent(context.Background(), + &user.NewAggregate("org1", "org1").Aggregate, + true, + true, + true, + ), + ), + ), + expectFilter( + eventFromEventPusher( + org.NewPasswordComplexityPolicyAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + 1, + false, + false, + false, + false, + ), + ), + ), + expectFilter( + eventFromEventPusher( + org.NewLoginPolicyAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + false, + true, + true, + false, + false, + false, + false, + false, + false, + domain.PasswordlessTypeNotAllowed, + "", + time.Hour*1, + time.Hour*2, + time.Hour*3, + time.Hour*4, + time.Hour*5, + ), + ), + ), + expectFilter( + eventFromEventPusher( + org.NewIDPConfigAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + "idpID", + "name", + domain.IDPConfigTypeOIDC, + domain.IDPConfigStylingTypeUnspecified, + false, + ), + ), + eventFromEventPusher( + org.NewIDPOIDCConfigAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + "clientID", + "idpID", + "issuer", + "authEndpoint", + "tokenEndpoint", + nil, + domain.OIDCMappingFieldUnspecified, + domain.OIDCMappingFieldUnspecified, + ), + ), + eventFromEventPusher( + org.NewIdentityProviderAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + "idpID", + domain.IdentityProviderTypeOrg, + ), + ), + ), + expectPush( + []*repository.Event{ + eventFromEventPusher( + newRegisterHumanEvent("username", "password", false, ""), + ), + eventFromEventPusher( + user.NewUserIDPLinkAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "idpID", + "displayName", + "externalID", + ), + ), + eventFromEventPusher( + user.NewHumanEmailVerifiedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate), + ), + }, + uniqueConstraintsFromEventConstraint(user.NewAddUsernameUniqueConstraint("username", "org1", true)), + uniqueConstraintsFromEventConstraint(user.NewAddUserIDPLinkUniqueConstraint("idpID", "externalID")), + ), + ), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), + userPasswordAlg: crypto.CreateMockHashAlg(gomock.NewController(t)), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + human: &domain.Human{ + Username: "username", + Password: &domain.Password{ + SecretString: "password", + }, + Profile: &domain.Profile{ + FirstName: "firstname", + LastName: "lastname", + }, + Email: &domain.Email{ + EmailAddress: "email@test.ch", + IsEmailVerified: true, + }, + }, + link: &domain.UserIDPLink{ + IDPConfigID: "idpID", + ExternalUserID: "externalID", + DisplayName: "displayName", + }, + secretGenerator: GetMockSecretGenerator(t), + }, + res: res{ + want: &domain.Human{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "user1", + ResourceOwner: "org1", + }, + Username: "username", + Profile: &domain.Profile{ + FirstName: "firstname", + LastName: "lastname", + DisplayName: "firstname lastname", + PreferredLanguage: language.Und, + }, + Email: &domain.Email{ + EmailAddress: "email@test.ch", + IsEmailVerified: true, + }, + State: domain.UserStateActive, + }, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/proto/zitadel/management.proto b/proto/zitadel/management.proto index ce0f28f7fa..e74863a896 100644 --- a/proto/zitadel/management.proto +++ b/proto/zitadel/management.proto @@ -3128,6 +3128,14 @@ message ImportHumanUserRequest { string value = 1; string algorithm = 2; } + message IDP { + // internal id of the IDP in ZITADEL + string config_id = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; + // id of the user on the IDP + string external_user_id = 2 [(validate.rules).string = {min_len: 1, max_len: 200}]; + // (display) name of the user on the IDP + string display_name = 3 [(validate.rules).string = {max_len: 200}]; + } string user_name = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; @@ -3138,8 +3146,8 @@ message ImportHumanUserRequest { HashedPassword hashed_password = 6; bool password_change_required = 7; bool request_passwordless_registration = 8; - string otp_code = 9; + repeated IDP idps = 10; } message ImportHumanUserResponse {