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:
Lars
2025-01-14 15:44:41 +01:00
committed by GitHub
parent 84997ffe1a
commit d01d003a03
20 changed files with 1029 additions and 95 deletions

View File

@@ -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)
}

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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 {