feat: patch user scim v2 endpoint (#9219)

# Which Problems Are Solved
* Adds support for the patch user SCIM v2 endpoint

# How the Problems Are Solved
* Adds support for the patch user SCIM v2 endpoint under `PATCH
/scim/v2/{orgID}/Users/{id}`

# Additional Context
Part of #8140
This commit is contained in:
Lars
2025-01-27 13:36:07 +01:00
committed by GitHub
parent ec5f18c168
commit 189f9770c6
31 changed files with 3601 additions and 125 deletions

View File

@@ -6,16 +6,32 @@ import (
"github.com/zitadel/zitadel/internal/command/preparation"
"github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/zerrors"
)
type Phone struct {
Number domain.PhoneNumber
Remove bool
Verified bool
// ReturnCode is used if the Verified field is false
ReturnCode bool
}
func (p *Phone) Validate() (err error) {
if p.Remove && p.Number != "" {
return zerrors.ThrowInvalidArgumentf(nil, "USRP2-12881", "Cannot update and remove the phone number at the same time")
}
if p.Number != "" {
if p.Number, err = p.Number.Normalize(); err != nil {
return err
}
}
return nil
}
// newPhoneCode generates a new code to be sent out to via SMS or
// returns the ID of the external code provider (e.g. when using Twilio verification API)
func (c *Commands) newPhoneCode(ctx context.Context, filter preparation.FilterToQueryReducer, secretGeneratorType domain.SecretGeneratorType, alg crypto.EncryptionAlgorithm, defaultConfig *crypto.GeneratorConfig) (*EncryptedCode, string, error) {

View File

@@ -14,12 +14,13 @@ import (
)
type ChangeHuman struct {
ID string
State *domain.UserState
Username *string
Profile *Profile
Email *Email
Phone *Phone
ID string
State *domain.UserState
Username *string
Profile *Profile
Email *Email
Phone *Phone
Metadata []*domain.Metadata
MetadataKeysToRemove []string
@@ -61,8 +62,8 @@ func (h *ChangeHuman) Validate(hasher *crypto.Hasher) (err error) {
}
}
if h.Phone != nil && h.Phone.Number != "" {
if h.Phone.Number, err = h.Phone.Number.Normalize(); err != nil {
if h.Phone != nil {
if err := h.Phone.Validate(); err != nil {
return err
}
}
@@ -263,7 +264,7 @@ func (c *Commands) ChangeUserHuman(ctx context.Context, human *ChangeHuman, alg
return err
}
existingHuman, err := c.userHumanWriteModel(
existingHuman, err := c.UserHumanWriteModel(
ctx,
human.ID,
human.Profile != nil,
@@ -272,13 +273,11 @@ func (c *Commands) ChangeUserHuman(ctx context.Context, human *ChangeHuman, alg
human.Password != nil,
false, // avatar not updateable
false, // IDPLinks not updateable
len(human.Metadata) > 0 || len(human.MetadataKeysToRemove) > 0,
)
if err != nil {
return err
}
if !isUserStateExists(existingHuman.UserState) {
return zerrors.ThrowNotFound(nil, "COMMAND-ugjs0upun6", "Errors.User.NotFound")
}
if human.Changed() {
if err := c.checkPermissionUpdateUser(ctx, existingHuman.ResourceOwner, existingHuman.AggregateID); err != nil {
@@ -415,6 +414,10 @@ func (c *Commands) changeUserPhone(ctx context.Context, cmds []eventstore.Comman
ctx, span := tracing.NewSpan(ctx)
defer func() { span.End() }()
if phone.Remove {
return append(cmds, user.NewHumanPhoneRemovedEvent(ctx, &wm.Aggregate().Aggregate)), nil, nil
}
if phone.Number != "" && phone.Number != wm.Phone {
cmds = append(cmds, user.NewHumanPhoneChangedEvent(ctx, &wm.Aggregate().Aggregate, phone.Number))
@@ -432,6 +435,7 @@ func (c *Commands) changeUserPhone(ctx context.Context, cmds []eventstore.Comman
return cmds, code, nil
}
}
// only create separate event of verified if email was not changed
if phone.Verified && wm.IsPhoneVerified != phone.Verified {
return append(cmds, user.NewHumanPhoneVerifiedEvent(ctx, &wm.Aggregate().Aggregate)), code, nil
@@ -499,14 +503,19 @@ func (c *Commands) userExistsWriteModel(ctx context.Context, userID string) (wri
return writeModel, nil
}
func (c *Commands) userHumanWriteModel(ctx context.Context, userID string, profileWM, emailWM, phoneWM, passwordWM, avatarWM, idpLinksWM bool) (writeModel *UserV2WriteModel, err error) {
func (c *Commands) UserHumanWriteModel(ctx context.Context, userID string, profileWM, emailWM, phoneWM, passwordWM, avatarWM, idpLinksWM, metadataWM bool) (writeModel *UserV2WriteModel, err error) {
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
writeModel = NewUserHumanWriteModel(userID, "", profileWM, emailWM, phoneWM, passwordWM, avatarWM, idpLinksWM)
writeModel = NewUserHumanWriteModel(userID, "", profileWM, emailWM, phoneWM, passwordWM, avatarWM, idpLinksWM, metadataWM)
err = c.eventstore.FilterToQueryReducer(ctx, writeModel)
if err != nil {
return nil, err
}
if !isUserStateExists(writeModel.UserState) {
return nil, zerrors.ThrowNotFound(nil, "COMMAND-ugjs0upun6", "Errors.User.NotFound")
}
return writeModel, nil
}

View File

@@ -15,6 +15,8 @@ import (
type UserV2WriteModel struct {
eventstore.WriteModel
CreationDate time.Time
UserName string
MachineWriteModel bool
@@ -73,6 +75,9 @@ type UserV2WriteModel struct {
IDPLinkWriteModel bool
IDPLinks []*domain.UserIDPLink
MetadataWriteModel bool
Metadata map[string][]byte
}
func NewUserExistsWriteModel(userID, resourceOwner string) *UserV2WriteModel {
@@ -87,7 +92,7 @@ func NewUserRemoveWriteModel(userID, resourceOwner string) *UserV2WriteModel {
return newUserV2WriteModel(userID, resourceOwner, WithHuman(), WithMachine(), WithState(), WithIDPLinks())
}
func NewUserHumanWriteModel(userID, resourceOwner string, profileWM, emailWM, phoneWM, passwordWM, avatarWM, idpLinks bool) *UserV2WriteModel {
func NewUserHumanWriteModel(userID, resourceOwner string, profileWM, emailWM, phoneWM, passwordWM, avatarWM, idpLinks, metadataListWM bool) *UserV2WriteModel {
opts := []UserV2WMOption{WithHuman(), WithState()}
if profileWM {
opts = append(opts, WithProfile())
@@ -107,6 +112,9 @@ func NewUserHumanWriteModel(userID, resourceOwner string, profileWM, emailWM, ph
if idpLinks {
opts = append(opts, WithIDPLinks())
}
if metadataListWM {
opts = append(opts, WithMetadata())
}
return newUserV2WriteModel(userID, resourceOwner, opts...)
}
@@ -172,6 +180,12 @@ func WithIDPLinks() UserV2WMOption {
}
}
func WithMetadata() UserV2WMOption {
return func(o *UserV2WriteModel) {
o.MetadataWriteModel = true
}
}
func (wm *UserV2WriteModel) Reduce() error {
for _, event := range wm.Events {
switch e := event.(type) {
@@ -281,6 +295,17 @@ func (wm *UserV2WriteModel) Reduce() error {
wm.RemoveIDPLink(e.IDPConfigID, e.ExternalUserID)
case *user.UserIDPLinkCascadeRemovedEvent:
wm.RemoveIDPLink(e.IDPConfigID, e.ExternalUserID)
case *user.MetadataSetEvent:
if wm.Metadata == nil {
wm.Metadata = make(map[string][]byte)
}
wm.Metadata[e.Key] = e.Value
case *user.MetadataRemovedEvent:
wm.Metadata[e.Key] = nil
delete(wm.Metadata, e.Key)
case *user.MetadataRemovedAllEvent:
wm.Metadata = nil
}
}
return wm.WriteModel.Reduce()
@@ -457,6 +482,13 @@ func (wm *UserV2WriteModel) Query() *eventstore.SearchQueryBuilder {
)
}
if wm.MetadataWriteModel {
eventTypes = append(eventTypes,
user.MetadataSetType,
user.MetadataRemovedType,
user.MetadataRemovedAllType)
}
query := eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent).
AddQuery().
AggregateTypes(user.AggregateType).
@@ -482,6 +514,7 @@ func (wm *UserV2WriteModel) reduceHumanAddedEvent(e *user.HumanAddedEvent) {
wm.UserState = domain.UserStateActive
wm.PasswordEncodedHash = crypto.SecretOrEncodedHash(e.Secret, e.EncodedHash)
wm.PasswordChangeRequired = e.ChangeRequired
wm.CreationDate = e.Creation
}
func (wm *UserV2WriteModel) reduceHumanRegisteredEvent(e *user.HumanRegisteredEvent) {

View File

@@ -567,7 +567,7 @@ func TestCommandSide_userHumanWriteModel_profile(t *testing.T) {
r := &Commands{
eventstore: tt.fields.eventstore(t),
}
wm, err := r.userHumanWriteModel(tt.args.ctx, tt.args.userID, true, false, false, false, false, false)
wm, err := r.UserHumanWriteModel(tt.args.ctx, tt.args.userID, true, false, false, false, false, false, false)
if tt.res.err == nil {
if !assert.NoError(t, err) {
t.FailNow()
@@ -912,7 +912,7 @@ func TestCommandSide_userHumanWriteModel_email(t *testing.T) {
r := &Commands{
eventstore: tt.fields.eventstore(t),
}
wm, err := r.userHumanWriteModel(tt.args.ctx, tt.args.userID, false, true, false, false, false, false)
wm, err := r.UserHumanWriteModel(tt.args.ctx, tt.args.userID, false, true, false, false, false, false, false)
if tt.res.err == nil {
if !assert.NoError(t, err) {
t.FailNow()
@@ -1344,7 +1344,7 @@ func TestCommandSide_userHumanWriteModel_phone(t *testing.T) {
r := &Commands{
eventstore: tt.fields.eventstore(t),
}
wm, err := r.userHumanWriteModel(tt.args.ctx, tt.args.userID, false, false, true, false, false, false)
wm, err := r.UserHumanWriteModel(tt.args.ctx, tt.args.userID, false, false, true, false, false, false, false)
if tt.res.err == nil {
if !assert.NoError(t, err) {
t.FailNow()
@@ -1605,7 +1605,7 @@ func TestCommandSide_userHumanWriteModel_password(t *testing.T) {
r := &Commands{
eventstore: tt.fields.eventstore(t),
}
wm, err := r.userHumanWriteModel(tt.args.ctx, tt.args.userID, false, false, false, true, false, false)
wm, err := r.UserHumanWriteModel(tt.args.ctx, tt.args.userID, false, false, false, true, false, false, false)
if tt.res.err == nil {
if !assert.NoError(t, err) {
t.FailNow()
@@ -2132,7 +2132,7 @@ func TestCommandSide_userHumanWriteModel_avatar(t *testing.T) {
r := &Commands{
eventstore: tt.fields.eventstore(t),
}
wm, err := r.userHumanWriteModel(tt.args.ctx, tt.args.userID, false, false, false, false, true, false)
wm, err := r.UserHumanWriteModel(tt.args.ctx, tt.args.userID, false, false, false, false, true, false, false)
if tt.res.err == nil {
if !assert.NoError(t, err) {
t.FailNow()
@@ -2456,3 +2456,306 @@ func TestCommandSide_userHumanWriteModel_idpLinks(t *testing.T) {
})
}
}
func TestCommandSide_userHumanWriteModel_metadata(t *testing.T) {
type fields struct {
eventstore func(t *testing.T) *eventstore.Eventstore
}
type args struct {
ctx context.Context
userID string
}
type res struct {
want *UserV2WriteModel
err func(error) bool
}
userAgg := user.NewAggregate("user1", "org1")
tests := []struct {
name string
fields fields
args args
res res
}{
{
name: "user added with metadata",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
newAddHumanEvent("$plain$x$password", true, true, "", language.English),
),
eventFromEventPusher(
user.NewMetadataSetEvent(
context.Background(),
&userAgg.Aggregate,
"key",
[]byte("value"),
),
),
),
),
},
args: args{
ctx: context.Background(),
userID: "user1",
},
res: res{
want: &UserV2WriteModel{
HumanWriteModel: true,
StateWriteModel: true,
MetadataWriteModel: true,
WriteModel: eventstore.WriteModel{
AggregateID: "user1",
Events: []eventstore.Event{},
ProcessedSequence: 0,
ResourceOwner: "org1",
},
UserName: "username",
FirstName: "firstname",
LastName: "lastname",
DisplayName: "firstname lastname",
PreferredLanguage: language.English,
PasswordEncodedHash: "$plain$x$password",
PasswordChangeRequired: true,
Email: "email@test.ch",
IsEmailVerified: false,
UserState: domain.UserStateActive,
Metadata: map[string][]byte{
"key": []byte("value"),
},
},
},
},
{
name: "user added with multiple metadata",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
newAddHumanEvent("$plain$x$password", true, true, "", language.English),
),
eventFromEventPusher(
user.NewMetadataSetEvent(
context.Background(),
&userAgg.Aggregate,
"key1",
[]byte("value1"),
),
),
eventFromEventPusher(
user.NewMetadataSetEvent(
context.Background(),
&userAgg.Aggregate,
"key2",
[]byte("value2"),
),
),
eventFromEventPusher(
user.NewMetadataSetEvent(
context.Background(),
&userAgg.Aggregate,
"key3",
[]byte("value3"),
),
),
),
),
},
args: args{
ctx: context.Background(),
userID: "user1",
},
res: res{
want: &UserV2WriteModel{
HumanWriteModel: true,
StateWriteModel: true,
MetadataWriteModel: true,
WriteModel: eventstore.WriteModel{
AggregateID: "user1",
Events: []eventstore.Event{},
ProcessedSequence: 0,
ResourceOwner: "org1",
},
UserName: "username",
FirstName: "firstname",
LastName: "lastname",
DisplayName: "firstname lastname",
PreferredLanguage: language.English,
PasswordEncodedHash: "$plain$x$password",
PasswordChangeRequired: true,
Email: "email@test.ch",
IsEmailVerified: false,
UserState: domain.UserStateActive,
Metadata: map[string][]byte{
"key1": []byte("value1"),
"key2": []byte("value2"),
"key3": []byte("value3"),
},
},
},
},
{
name: "user added with metadata add and remove",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
newAddHumanEvent("$plain$x$password", true, true, "", language.English),
),
eventFromEventPusher(
user.NewMetadataSetEvent(
context.Background(),
&userAgg.Aggregate,
"key1",
[]byte("value1"),
),
),
eventFromEventPusher(
user.NewMetadataSetEvent(
context.Background(),
&userAgg.Aggregate,
"key2",
[]byte("name2"),
),
),
eventFromEventPusher(
user.NewMetadataSetEvent(
context.Background(),
&userAgg.Aggregate,
"key3",
[]byte("value3"),
),
),
eventFromEventPusher(
user.NewMetadataRemovedEvent(
context.Background(),
&userAgg.Aggregate,
"key2",
),
),
eventFromEventPusher(
user.NewMetadataRemovedEvent(
context.Background(),
&userAgg.Aggregate,
"key3",
),
),
),
),
},
args: args{
ctx: context.Background(),
userID: "user1",
},
res: res{
want: &UserV2WriteModel{
HumanWriteModel: true,
StateWriteModel: true,
MetadataWriteModel: true,
WriteModel: eventstore.WriteModel{
AggregateID: "user1",
Events: []eventstore.Event{},
ProcessedSequence: 0,
ResourceOwner: "org1",
},
UserName: "username",
FirstName: "firstname",
LastName: "lastname",
DisplayName: "firstname lastname",
PreferredLanguage: language.English,
PasswordEncodedHash: "$plain$x$password",
PasswordChangeRequired: true,
Email: "email@test.ch",
IsEmailVerified: false,
UserState: domain.UserStateActive,
Metadata: map[string][]byte{
"key1": []byte("value1"),
},
},
},
},
{
name: "user added with added metadata and removed all",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
newAddHumanEvent("$plain$x$password", true, true, "", language.English),
),
eventFromEventPusher(
user.NewMetadataSetEvent(
context.Background(),
&userAgg.Aggregate,
"key1",
[]byte("value1"),
),
),
eventFromEventPusher(
user.NewMetadataSetEvent(
context.Background(),
&userAgg.Aggregate,
"key2",
[]byte("value2"),
),
),
eventFromEventPusher(
user.NewMetadataRemovedAllEvent(
context.Background(),
&userAgg.Aggregate,
),
),
),
),
},
args: args{
ctx: context.Background(),
userID: "user1",
},
res: res{
want: &UserV2WriteModel{
HumanWriteModel: true,
StateWriteModel: true,
MetadataWriteModel: true,
WriteModel: eventstore.WriteModel{
AggregateID: "user1",
Events: []eventstore.Event{},
ProcessedSequence: 0,
ResourceOwner: "org1",
},
UserName: "username",
FirstName: "firstname",
LastName: "lastname",
DisplayName: "firstname lastname",
PreferredLanguage: language.English,
PasswordEncodedHash: "$plain$x$password",
PasswordChangeRequired: true,
Email: "email@test.ch",
IsEmailVerified: false,
UserState: domain.UserStateActive,
Metadata: nil,
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := &Commands{
eventstore: tt.fields.eventstore(t),
}
wm, err := r.UserHumanWriteModel(tt.args.ctx, tt.args.userID, false, false, false, false, false, false, true)
if tt.res.err == nil {
if !assert.NoError(t, err) {
t.FailNow()
}
} else if !tt.res.err(err) {
t.Errorf("got wrong err: %v ", err)
return
}
if tt.res.err == nil {
assert.Equal(t, tt.res.want, wm)
}
})
}
}