mirror of
https://github.com/zitadel/zitadel.git
synced 2025-12-07 07:16:54 +00:00
feat: List users by metadata (#10415)
# Which Problems Are Solved
Some users have reported the need of retrieving users given a metadata
key, metadata value or both. This change introduces metadata search
filter on the `ListUsers()` endpoint to allow Zitadel users to search
for user records by metadata.
The changes affect only v2 APIs.
# How the Problems Are Solved
- Add new search filter to `ListUserRequest`: `MetaKey` and `MetaValue`
- Add SQL indices on metadata key and metadata value
- Update query to left join `user_metadata` table
# Additional Context
- Closes #9053
- Depends on https://github.com/zitadel/zitadel/pull/10567
---------
Co-authored-by: Silvan <27845747+adlerhurst@users.noreply.github.com>
Co-authored-by: Stefan Benz <46600784+stebenz@users.noreply.github.com>
(cherry picked from commit 8df402fb4f)
This commit is contained in:
@@ -49,6 +49,8 @@ func (*userMetadataProjection) Init() *old_handler.Check {
|
||||
},
|
||||
handler.NewPrimaryKey(UserMetadataColumnInstanceID, UserMetadataColumnUserID, UserMetadataColumnKey),
|
||||
handler.WithIndex(handler.NewIndex("resource_owner", []string{UserGrantResourceOwner})),
|
||||
handler.WithIndex(handler.NewIndex("metadata_key", []string{UserMetadataColumnKey})),
|
||||
handler.WithIndex(handler.NewIndex("metadata_value", []string{UserMetadataColumnValue})),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -863,27 +863,7 @@ func scanUser(row *sql.Row) (*User, error) {
|
||||
var count int
|
||||
preferredLoginName := sql.NullString{}
|
||||
|
||||
humanID := sql.NullString{}
|
||||
firstName := sql.NullString{}
|
||||
lastName := sql.NullString{}
|
||||
nickName := sql.NullString{}
|
||||
displayName := sql.NullString{}
|
||||
preferredLanguage := sql.NullString{}
|
||||
gender := sql.NullInt32{}
|
||||
avatarKey := sql.NullString{}
|
||||
email := sql.NullString{}
|
||||
isEmailVerified := sql.NullBool{}
|
||||
phone := sql.NullString{}
|
||||
isPhoneVerified := sql.NullBool{}
|
||||
passwordChangeRequired := sql.NullBool{}
|
||||
passwordChanged := sql.NullTime{}
|
||||
mfaInitSkipped := sql.NullTime{}
|
||||
|
||||
machineID := sql.NullString{}
|
||||
name := sql.NullString{}
|
||||
description := sql.NullString{}
|
||||
encodedHash := sql.NullString{}
|
||||
accessTokenType := sql.NullInt32{}
|
||||
human, machine := sqlHuman{}, sqlMachine{}
|
||||
|
||||
err := row.Scan(
|
||||
&u.ID,
|
||||
@@ -896,26 +876,26 @@ func scanUser(row *sql.Row) (*User, error) {
|
||||
&u.Username,
|
||||
&u.LoginNames,
|
||||
&preferredLoginName,
|
||||
&humanID,
|
||||
&firstName,
|
||||
&lastName,
|
||||
&nickName,
|
||||
&displayName,
|
||||
&preferredLanguage,
|
||||
&gender,
|
||||
&avatarKey,
|
||||
&email,
|
||||
&isEmailVerified,
|
||||
&phone,
|
||||
&isPhoneVerified,
|
||||
&passwordChangeRequired,
|
||||
&passwordChanged,
|
||||
&mfaInitSkipped,
|
||||
&machineID,
|
||||
&name,
|
||||
&description,
|
||||
&encodedHash,
|
||||
&accessTokenType,
|
||||
&human.humanID,
|
||||
&human.firstName,
|
||||
&human.lastName,
|
||||
&human.nickName,
|
||||
&human.displayName,
|
||||
&human.preferredLanguage,
|
||||
&human.gender,
|
||||
&human.avatarKey,
|
||||
&human.email,
|
||||
&human.isEmailVerified,
|
||||
&human.phone,
|
||||
&human.isPhoneVerified,
|
||||
&human.passwordChangeRequired,
|
||||
&human.passwordChanged,
|
||||
&human.mfaInitSkipped,
|
||||
&machine.machineID,
|
||||
&machine.name,
|
||||
&machine.description,
|
||||
&machine.encodedSecret,
|
||||
&machine.accessTokenType,
|
||||
&count,
|
||||
)
|
||||
|
||||
@@ -928,29 +908,29 @@ func scanUser(row *sql.Row) (*User, error) {
|
||||
|
||||
u.PreferredLoginName = preferredLoginName.String
|
||||
|
||||
if humanID.Valid {
|
||||
if human.humanID.Valid {
|
||||
u.Human = &Human{
|
||||
FirstName: firstName.String,
|
||||
LastName: lastName.String,
|
||||
NickName: nickName.String,
|
||||
DisplayName: displayName.String,
|
||||
AvatarKey: avatarKey.String,
|
||||
PreferredLanguage: language.Make(preferredLanguage.String),
|
||||
Gender: domain.Gender(gender.Int32),
|
||||
Email: domain.EmailAddress(email.String),
|
||||
IsEmailVerified: isEmailVerified.Bool,
|
||||
Phone: domain.PhoneNumber(phone.String),
|
||||
IsPhoneVerified: isPhoneVerified.Bool,
|
||||
PasswordChangeRequired: passwordChangeRequired.Bool,
|
||||
PasswordChanged: passwordChanged.Time,
|
||||
MFAInitSkipped: mfaInitSkipped.Time,
|
||||
FirstName: human.firstName.String,
|
||||
LastName: human.lastName.String,
|
||||
NickName: human.nickName.String,
|
||||
DisplayName: human.displayName.String,
|
||||
AvatarKey: human.avatarKey.String,
|
||||
PreferredLanguage: language.Make(human.preferredLanguage.String),
|
||||
Gender: domain.Gender(human.gender.Int32),
|
||||
Email: domain.EmailAddress(human.email.String),
|
||||
IsEmailVerified: human.isEmailVerified.Bool,
|
||||
Phone: domain.PhoneNumber(human.phone.String),
|
||||
IsPhoneVerified: human.isPhoneVerified.Bool,
|
||||
PasswordChangeRequired: human.passwordChangeRequired.Bool,
|
||||
PasswordChanged: human.passwordChanged.Time,
|
||||
MFAInitSkipped: human.mfaInitSkipped.Time,
|
||||
}
|
||||
} else if machineID.Valid {
|
||||
} else if machine.machineID.Valid {
|
||||
u.Machine = &Machine{
|
||||
Name: name.String,
|
||||
Description: description.String,
|
||||
EncodedSecret: encodedHash.String,
|
||||
AccessTokenType: domain.OIDCTokenType(accessTokenType.Int32),
|
||||
Name: machine.name.String,
|
||||
Description: machine.description.String,
|
||||
EncodedSecret: machine.encodedSecret.String,
|
||||
AccessTokenType: domain.OIDCTokenType(machine.accessTokenType.Int32),
|
||||
}
|
||||
}
|
||||
return u, nil
|
||||
@@ -1316,15 +1296,18 @@ func prepareUsersQuery() (sq.SelectBuilder, func(*sql.Rows) (*Users, error)) {
|
||||
HumanIsPhoneVerifiedCol.identifier(),
|
||||
HumanPasswordChangeRequiredCol.identifier(),
|
||||
HumanPasswordChangedCol.identifier(),
|
||||
HumanMFAInitSkippedCol.identifier(),
|
||||
MachineUserIDCol.identifier(),
|
||||
MachineNameCol.identifier(),
|
||||
MachineDescriptionCol.identifier(),
|
||||
MachineSecretCol.identifier(),
|
||||
MachineAccessTokenTypeCol.identifier(),
|
||||
countColumn.identifier()).
|
||||
Distinct().
|
||||
From(userTable.identifier()).
|
||||
LeftJoin(join(HumanUserIDCol, UserIDCol)).
|
||||
LeftJoin(join(MachineUserIDCol, UserIDCol)).
|
||||
LeftJoin(join(UserMetadataUserIDCol, UserIDCol)).
|
||||
JoinClause(joinLoginNames).
|
||||
PlaceholderFormat(sq.Dollar),
|
||||
func(rows *sql.Rows) (*Users, error) {
|
||||
@@ -1335,26 +1318,7 @@ func prepareUsersQuery() (sq.SelectBuilder, func(*sql.Rows) (*Users, error)) {
|
||||
loginNames := database.TextArray[string]{}
|
||||
preferredLoginName := sql.NullString{}
|
||||
|
||||
humanID := sql.NullString{}
|
||||
firstName := sql.NullString{}
|
||||
lastName := sql.NullString{}
|
||||
nickName := sql.NullString{}
|
||||
displayName := sql.NullString{}
|
||||
preferredLanguage := sql.NullString{}
|
||||
gender := sql.NullInt32{}
|
||||
avatarKey := sql.NullString{}
|
||||
email := sql.NullString{}
|
||||
isEmailVerified := sql.NullBool{}
|
||||
phone := sql.NullString{}
|
||||
isPhoneVerified := sql.NullBool{}
|
||||
passwordChangeRequired := sql.NullBool{}
|
||||
passwordChanged := sql.NullTime{}
|
||||
|
||||
machineID := sql.NullString{}
|
||||
name := sql.NullString{}
|
||||
description := sql.NullString{}
|
||||
encodedHash := sql.NullString{}
|
||||
accessTokenType := sql.NullInt32{}
|
||||
human, machine := sqlHuman{}, sqlMachine{}
|
||||
|
||||
err := rows.Scan(
|
||||
&u.ID,
|
||||
@@ -1367,25 +1331,29 @@ func prepareUsersQuery() (sq.SelectBuilder, func(*sql.Rows) (*Users, error)) {
|
||||
&u.Username,
|
||||
&loginNames,
|
||||
&preferredLoginName,
|
||||
&humanID,
|
||||
&firstName,
|
||||
&lastName,
|
||||
&nickName,
|
||||
&displayName,
|
||||
&preferredLanguage,
|
||||
&gender,
|
||||
&avatarKey,
|
||||
&email,
|
||||
&isEmailVerified,
|
||||
&phone,
|
||||
&isPhoneVerified,
|
||||
&passwordChangeRequired,
|
||||
&passwordChanged,
|
||||
&machineID,
|
||||
&name,
|
||||
&description,
|
||||
&encodedHash,
|
||||
&accessTokenType,
|
||||
|
||||
&human.humanID,
|
||||
&human.firstName,
|
||||
&human.lastName,
|
||||
&human.nickName,
|
||||
&human.displayName,
|
||||
&human.preferredLanguage,
|
||||
&human.gender,
|
||||
&human.avatarKey,
|
||||
&human.email,
|
||||
&human.isEmailVerified,
|
||||
&human.phone,
|
||||
&human.isPhoneVerified,
|
||||
&human.passwordChangeRequired,
|
||||
&human.passwordChanged,
|
||||
&human.mfaInitSkipped,
|
||||
|
||||
&machine.machineID,
|
||||
&machine.name,
|
||||
&machine.description,
|
||||
&machine.encodedSecret,
|
||||
&machine.accessTokenType,
|
||||
|
||||
&count,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -1397,28 +1365,29 @@ func prepareUsersQuery() (sq.SelectBuilder, func(*sql.Rows) (*Users, error)) {
|
||||
u.PreferredLoginName = preferredLoginName.String
|
||||
}
|
||||
|
||||
if humanID.Valid {
|
||||
if human.humanID.Valid {
|
||||
u.Human = &Human{
|
||||
FirstName: firstName.String,
|
||||
LastName: lastName.String,
|
||||
NickName: nickName.String,
|
||||
DisplayName: displayName.String,
|
||||
AvatarKey: avatarKey.String,
|
||||
PreferredLanguage: language.Make(preferredLanguage.String),
|
||||
Gender: domain.Gender(gender.Int32),
|
||||
Email: domain.EmailAddress(email.String),
|
||||
IsEmailVerified: isEmailVerified.Bool,
|
||||
Phone: domain.PhoneNumber(phone.String),
|
||||
IsPhoneVerified: isPhoneVerified.Bool,
|
||||
PasswordChangeRequired: passwordChangeRequired.Bool,
|
||||
PasswordChanged: passwordChanged.Time,
|
||||
FirstName: human.firstName.String,
|
||||
LastName: human.lastName.String,
|
||||
NickName: human.nickName.String,
|
||||
DisplayName: human.displayName.String,
|
||||
AvatarKey: human.avatarKey.String,
|
||||
PreferredLanguage: language.Make(human.preferredLanguage.String),
|
||||
Gender: domain.Gender(human.gender.Int32),
|
||||
Email: domain.EmailAddress(human.email.String),
|
||||
IsEmailVerified: human.isEmailVerified.Bool,
|
||||
Phone: domain.PhoneNumber(human.phone.String),
|
||||
IsPhoneVerified: human.isPhoneVerified.Bool,
|
||||
PasswordChangeRequired: human.passwordChangeRequired.Bool,
|
||||
PasswordChanged: human.passwordChanged.Time,
|
||||
MFAInitSkipped: human.mfaInitSkipped.Time,
|
||||
}
|
||||
} else if machineID.Valid {
|
||||
} else if machine.machineID.Valid {
|
||||
u.Machine = &Machine{
|
||||
Name: name.String,
|
||||
Description: description.String,
|
||||
EncodedSecret: encodedHash.String,
|
||||
AccessTokenType: domain.OIDCTokenType(accessTokenType.Int32),
|
||||
Name: machine.name.String,
|
||||
Description: machine.description.String,
|
||||
EncodedSecret: machine.encodedSecret.String,
|
||||
AccessTokenType: domain.OIDCTokenType(machine.accessTokenType.Int32),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1437,3 +1406,29 @@ func prepareUsersQuery() (sq.SelectBuilder, func(*sql.Rows) (*Users, error)) {
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
type sqlHuman struct {
|
||||
humanID sql.NullString
|
||||
firstName sql.NullString
|
||||
lastName sql.NullString
|
||||
nickName sql.NullString
|
||||
displayName sql.NullString
|
||||
preferredLanguage sql.NullString
|
||||
gender sql.NullInt32
|
||||
avatarKey sql.NullString
|
||||
email sql.NullString
|
||||
isEmailVerified sql.NullBool
|
||||
phone sql.NullString
|
||||
isPhoneVerified sql.NullBool
|
||||
passwordChangeRequired sql.NullBool
|
||||
passwordChanged sql.NullTime
|
||||
mfaInitSkipped sql.NullTime
|
||||
}
|
||||
|
||||
type sqlMachine struct {
|
||||
machineID sql.NullString
|
||||
name sql.NullString
|
||||
description sql.NullString
|
||||
encodedSecret sql.NullString
|
||||
accessTokenType sql.NullInt32
|
||||
}
|
||||
|
||||
@@ -366,7 +366,7 @@ var (
|
||||
"password_set",
|
||||
"count",
|
||||
}
|
||||
usersQuery = `SELECT projections.users14.id,` +
|
||||
usersQuery = `SELECT DISTINCT projections.users14.id,` +
|
||||
` projections.users14.creation_date,` +
|
||||
` projections.users14.change_date,` +
|
||||
` projections.users14.resource_owner,` +
|
||||
@@ -390,6 +390,7 @@ var (
|
||||
` projections.users14_humans.is_phone_verified,` +
|
||||
` projections.users14_humans.password_change_required,` +
|
||||
` projections.users14_humans.password_changed,` +
|
||||
` projections.users14_humans.mfa_init_skipped,` +
|
||||
` projections.users14_machines.user_id,` +
|
||||
` projections.users14_machines.name,` +
|
||||
` projections.users14_machines.description,` +
|
||||
@@ -399,6 +400,7 @@ var (
|
||||
` FROM projections.users14` +
|
||||
` LEFT JOIN projections.users14_humans ON projections.users14.id = projections.users14_humans.user_id AND projections.users14.instance_id = projections.users14_humans.instance_id` +
|
||||
` LEFT JOIN projections.users14_machines ON projections.users14.id = projections.users14_machines.user_id AND projections.users14.instance_id = projections.users14_machines.instance_id` +
|
||||
` LEFT JOIN projections.user_metadata5 ON projections.users14.id = projections.user_metadata5.user_id AND projections.users14.instance_id = projections.user_metadata5.instance_id` +
|
||||
` LEFT JOIN LATERAL (SELECT ARRAY_AGG(ln.login_name ORDER BY ln.login_name) AS login_names, MAX(CASE WHEN ln.is_primary THEN ln.login_name ELSE NULL END) AS preferred_login_name FROM projections.login_names3 AS ln WHERE ln.user_id = projections.users14.id AND ln.instance_id = projections.users14.instance_id) AS login_names ON TRUE`
|
||||
usersCols = []string{
|
||||
"id",
|
||||
@@ -426,6 +428,7 @@ var (
|
||||
"is_phone_verified",
|
||||
"password_change_required",
|
||||
"password_changed",
|
||||
"mfa_init_skipped",
|
||||
// machine
|
||||
"user_id",
|
||||
"name",
|
||||
@@ -992,6 +995,7 @@ func Test_UserPrepares(t *testing.T) {
|
||||
true,
|
||||
true,
|
||||
testNow,
|
||||
testNow,
|
||||
// machine
|
||||
nil,
|
||||
nil,
|
||||
@@ -1032,6 +1036,7 @@ func Test_UserPrepares(t *testing.T) {
|
||||
IsPhoneVerified: true,
|
||||
PasswordChangeRequired: true,
|
||||
PasswordChanged: testNow,
|
||||
MFAInitSkipped: testNow,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1071,6 +1076,7 @@ func Test_UserPrepares(t *testing.T) {
|
||||
true,
|
||||
true,
|
||||
testNow,
|
||||
testNow,
|
||||
// machine
|
||||
nil,
|
||||
nil,
|
||||
@@ -1104,6 +1110,7 @@ func Test_UserPrepares(t *testing.T) {
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
// machine
|
||||
"id",
|
||||
"name",
|
||||
@@ -1144,6 +1151,7 @@ func Test_UserPrepares(t *testing.T) {
|
||||
IsPhoneVerified: true,
|
||||
PasswordChangeRequired: true,
|
||||
PasswordChanged: testNow,
|
||||
MFAInitSkipped: testNow,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user