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:
Marco A.
2025-09-01 18:12:36 +02:00
committed by Livio Spring
parent d7f202d20f
commit 8cf623d5b5
21 changed files with 674 additions and 139 deletions

View File

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

View File

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

View File

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