mirror of
https://github.com/zitadel/zitadel.git
synced 2025-08-12 03:57:32 +00:00
feat: replace user scim v2 endpoint (#9163)
# Which Problems Are Solved - Adds support for the replace user SCIM v2 endpoint # How the Problems Are Solved - Adds support for the replace user SCIM v2 endpoint under `PUT /scim/v2/{orgID}/Users/{id}` # Additional Changes - Respect the `Active` field in the SCIM v2 create user endpoint `POST /scim/v2/{orgID}/Users` - Eventually consistent read endpoints used in SCIM tests are wrapped in `assert.EventuallyWithT` to work around race conditions # Additional Context Part of #8140
This commit is contained in:
@@ -19,6 +19,7 @@ type ResourceHandler[T ResourceHolder] interface {
|
||||
NewResource() T
|
||||
|
||||
Create(ctx context.Context, resource T) (T, error)
|
||||
Replace(ctx context.Context, id string, resource T) (T, error)
|
||||
Delete(ctx context.Context, id string) error
|
||||
Get(ctx context.Context, id string) (T, error)
|
||||
}
|
||||
|
@@ -47,6 +47,16 @@ func (adapter *ResourceHandlerAdapter[T]) Create(r *http.Request) (T, error) {
|
||||
return adapter.handler.Create(r.Context(), entity)
|
||||
}
|
||||
|
||||
func (adapter *ResourceHandlerAdapter[T]) Replace(r *http.Request) (T, error) {
|
||||
entity, err := adapter.readEntityFromBody(r)
|
||||
if err != nil {
|
||||
return entity, err
|
||||
}
|
||||
|
||||
id := mux.Vars(r)["id"]
|
||||
return adapter.handler.Replace(r.Context(), id, entity)
|
||||
}
|
||||
|
||||
func (adapter *ResourceHandlerAdapter[T]) Delete(r *http.Request) error {
|
||||
id := mux.Vars(r)["id"]
|
||||
return adapter.handler.Delete(r.Context(), id)
|
||||
|
@@ -140,8 +140,23 @@ func (h *UsersHandler) Create(ctx context.Context, user *ScimUser) (*ScimUser, e
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user.ID = addHuman.Details.ID
|
||||
user.Resource = buildResource(ctx, h, addHuman.Details)
|
||||
h.mapAddCommandToScimUser(ctx, user, addHuman)
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (h *UsersHandler) Replace(ctx context.Context, id string, user *ScimUser) (*ScimUser, error) {
|
||||
user.ID = id
|
||||
changeHuman, err := h.mapToChangeHuman(ctx, user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = h.command.ChangeUserHuman(ctx, changeHuman, h.userCodeAlg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
h.mapChangeCommandToScimUser(ctx, user, changeHuman)
|
||||
return user, nil
|
||||
}
|
||||
|
||||
|
@@ -17,14 +17,22 @@ import (
|
||||
)
|
||||
|
||||
func (h *UsersHandler) mapToAddHuman(ctx context.Context, scimUser *ScimUser) (*command.AddHuman, error) {
|
||||
// zitadel has its own state mechanism
|
||||
// ignore scimUser.Active
|
||||
human := &command.AddHuman{
|
||||
Username: scimUser.UserName,
|
||||
NickName: scimUser.NickName,
|
||||
DisplayName: scimUser.DisplayName,
|
||||
Email: h.mapPrimaryEmail(scimUser),
|
||||
Phone: h.mapPrimaryPhone(scimUser),
|
||||
}
|
||||
|
||||
if scimUser.Active != nil && !*scimUser.Active {
|
||||
human.SetInactive = true
|
||||
}
|
||||
|
||||
if email := h.mapPrimaryEmail(scimUser); email != nil {
|
||||
human.Email = *email
|
||||
}
|
||||
|
||||
if phone := h.mapPrimaryPhone(scimUser); phone != nil {
|
||||
human.Phone = *phone
|
||||
}
|
||||
|
||||
md, err := h.mapMetadataToCommands(ctx, scimUser)
|
||||
@@ -46,6 +54,9 @@ func (h *UsersHandler) mapToAddHuman(ctx context.Context, scimUser *ScimUser) (*
|
||||
// over the formatted name assignment
|
||||
if human.DisplayName == "" {
|
||||
human.DisplayName = scimUser.Name.Formatted
|
||||
} else {
|
||||
// update user to match the actual stored value
|
||||
scimUser.Name.Formatted = human.DisplayName
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,34 +68,144 @@ func (h *UsersHandler) mapToAddHuman(ctx context.Context, scimUser *ScimUser) (*
|
||||
return human, nil
|
||||
}
|
||||
|
||||
func (h *UsersHandler) mapPrimaryEmail(scimUser *ScimUser) command.Email {
|
||||
func (h *UsersHandler) mapToChangeHuman(ctx context.Context, scimUser *ScimUser) (*command.ChangeHuman, error) {
|
||||
human := &command.ChangeHuman{
|
||||
ID: scimUser.ID,
|
||||
Username: &scimUser.UserName,
|
||||
Profile: &command.Profile{
|
||||
NickName: &scimUser.NickName,
|
||||
DisplayName: &scimUser.DisplayName,
|
||||
},
|
||||
Email: h.mapPrimaryEmail(scimUser),
|
||||
Phone: h.mapPrimaryPhone(scimUser),
|
||||
}
|
||||
|
||||
if scimUser.Active != nil {
|
||||
if *scimUser.Active {
|
||||
human.State = gu.Ptr(domain.UserStateActive)
|
||||
} else {
|
||||
human.State = gu.Ptr(domain.UserStateInactive)
|
||||
}
|
||||
}
|
||||
|
||||
md, mdRemovedKeys, err := h.mapMetadataToDomain(ctx, scimUser)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
human.Metadata = md
|
||||
human.MetadataKeysToRemove = mdRemovedKeys
|
||||
|
||||
if scimUser.Password != nil {
|
||||
human.Password = &command.Password{
|
||||
Password: scimUser.Password.String(),
|
||||
}
|
||||
scimUser.Password = nil
|
||||
}
|
||||
|
||||
if scimUser.Name != nil {
|
||||
human.Profile.FirstName = &scimUser.Name.GivenName
|
||||
human.Profile.LastName = &scimUser.Name.FamilyName
|
||||
|
||||
// the direct mapping displayName => displayName has priority
|
||||
// over the formatted name assignment
|
||||
if *human.Profile.DisplayName == "" {
|
||||
human.Profile.DisplayName = &scimUser.Name.Formatted
|
||||
} else {
|
||||
// update user to match the actual stored value
|
||||
scimUser.Name.Formatted = *human.Profile.DisplayName
|
||||
}
|
||||
}
|
||||
|
||||
if err := domain.LanguageIsDefined(scimUser.PreferredLanguage); err != nil {
|
||||
human.Profile.PreferredLanguage = &language.English
|
||||
scimUser.PreferredLanguage = language.English
|
||||
}
|
||||
|
||||
return human, nil
|
||||
}
|
||||
|
||||
func (h *UsersHandler) mapPrimaryEmail(scimUser *ScimUser) *command.Email {
|
||||
for _, email := range scimUser.Emails {
|
||||
if !email.Primary {
|
||||
continue
|
||||
}
|
||||
|
||||
return command.Email{
|
||||
return &command.Email{
|
||||
Address: domain.EmailAddress(email.Value),
|
||||
Verified: h.config.EmailVerified,
|
||||
}
|
||||
}
|
||||
|
||||
return command.Email{}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *UsersHandler) mapPrimaryPhone(scimUser *ScimUser) command.Phone {
|
||||
func (h *UsersHandler) mapPrimaryPhone(scimUser *ScimUser) *command.Phone {
|
||||
for _, phone := range scimUser.PhoneNumbers {
|
||||
if !phone.Primary {
|
||||
continue
|
||||
}
|
||||
|
||||
return command.Phone{
|
||||
return &command.Phone{
|
||||
Number: domain.PhoneNumber(phone.Value),
|
||||
Verified: h.config.PhoneVerified,
|
||||
}
|
||||
}
|
||||
|
||||
return command.Phone{}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *UsersHandler) mapAddCommandToScimUser(ctx context.Context, user *ScimUser, addHuman *command.AddHuman) {
|
||||
user.ID = addHuman.Details.ID
|
||||
user.Resource = buildResource(ctx, h, addHuman.Details)
|
||||
user.Password = nil
|
||||
|
||||
// ZITADEL supports only one (primary) phone number or email.
|
||||
// Therefore, only the primary one should be returned.
|
||||
// Note that the phone number might also be reformatted.
|
||||
if addHuman.Phone.Number != "" {
|
||||
user.PhoneNumbers = []*ScimPhoneNumber{
|
||||
{
|
||||
Value: string(addHuman.Phone.Number),
|
||||
Primary: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if addHuman.Email.Address != "" {
|
||||
user.Emails = []*ScimEmail{
|
||||
{
|
||||
Value: string(addHuman.Email.Address),
|
||||
Primary: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *UsersHandler) mapChangeCommandToScimUser(ctx context.Context, user *ScimUser, changeHuman *command.ChangeHuman) {
|
||||
user.ID = changeHuman.Details.ID
|
||||
user.Resource = buildResource(ctx, h, changeHuman.Details)
|
||||
user.Password = nil
|
||||
|
||||
// ZITADEL supports only one (primary) phone number or email.
|
||||
// Therefore, only the primary one should be returned.
|
||||
// Note that the phone number might also be reformatted.
|
||||
if changeHuman.Phone != nil {
|
||||
user.PhoneNumbers = []*ScimPhoneNumber{
|
||||
{
|
||||
Value: string(changeHuman.Phone.Number),
|
||||
Primary: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if changeHuman.Email != nil {
|
||||
user.Emails = []*ScimEmail{
|
||||
{
|
||||
Value: string(changeHuman.Email.Address),
|
||||
Primary: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *UsersHandler) mapToScimUser(ctx context.Context, user *query.User, md map[metadata.ScopedKey][]byte) *ScimUser {
|
||||
|
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/zitadel/zitadel/internal/api/scim/schemas"
|
||||
"github.com/zitadel/zitadel/internal/api/scim/serrors"
|
||||
"github.com/zitadel/zitadel/internal/command"
|
||||
"github.com/zitadel/zitadel/internal/domain"
|
||||
"github.com/zitadel/zitadel/internal/query"
|
||||
"github.com/zitadel/zitadel/internal/zerrors"
|
||||
)
|
||||
@@ -55,6 +56,28 @@ func buildMetadataKeyQuery(ctx context.Context, key metadata.Key) query.SearchQu
|
||||
return q
|
||||
}
|
||||
|
||||
func (h *UsersHandler) mapMetadataToDomain(ctx context.Context, user *ScimUser) (md []*domain.Metadata, skippedMetadata []string, err error) {
|
||||
md = make([]*domain.Metadata, 0, len(metadata.ScimUserRelevantMetadataKeys))
|
||||
for _, key := range metadata.ScimUserRelevantMetadataKeys {
|
||||
var value []byte
|
||||
value, err = getValueForMetadataKey(user, key)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if len(value) > 0 {
|
||||
md = append(md, &domain.Metadata{
|
||||
Key: string(metadata.ScopeKey(ctx, key)),
|
||||
Value: value,
|
||||
})
|
||||
} else {
|
||||
skippedMetadata = append(skippedMetadata, string(metadata.ScopeKey(ctx, key)))
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (h *UsersHandler) mapMetadataToCommands(ctx context.Context, user *ScimUser) ([]*command.AddMetadataEntry, error) {
|
||||
md := make([]*command.AddMetadataEntry, 0, len(metadata.ScimUserRelevantMetadataKeys))
|
||||
for _, key := range metadata.ScimUserRelevantMetadataKeys {
|
||||
|
Reference in New Issue
Block a user