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,17 @@ func TimestampMethodPbToQuery(method filter.TimestampFilterMethod) query.Timesta
}
}
func ByteMethodPbToQuery(method filter.ByteFilterMethod) query.BytesComparison {
switch method {
case filter.ByteFilterMethod_BYTE_FILTER_METHOD_EQUALS:
return query.BytesEquals
case filter.ByteFilterMethod_BYTE_FILTER_METHOD_NOT_EQUALS:
return query.BytesNotEquals
default:
return -1
}
}
func PaginationPbToQuery(defaults systemdefaults.SystemDefaults, query *filter.PaginationRequest) (offset, limit uint64, asc bool, err error) {
limit = defaults.DefaultQueryLimit
if query == nil {

View File

@@ -0,0 +1,164 @@
package filter
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zitadel/zitadel/internal/config/systemdefaults"
"github.com/zitadel/zitadel/internal/query"
"github.com/zitadel/zitadel/pkg/grpc/filter/v2"
)
func TestTextMethodPbToQuery(t *testing.T) {
t.Parallel()
tt := []struct {
name string
input filter.TextFilterMethod
output query.TextComparison
}{
{"Equals", filter.TextFilterMethod_TEXT_FILTER_METHOD_EQUALS, query.TextEquals},
{"EqualsIgnoreCase", filter.TextFilterMethod_TEXT_FILTER_METHOD_EQUALS_IGNORE_CASE, query.TextEqualsIgnoreCase},
{"StartsWith", filter.TextFilterMethod_TEXT_FILTER_METHOD_STARTS_WITH, query.TextStartsWith},
{"StartsWithIgnoreCase", filter.TextFilterMethod_TEXT_FILTER_METHOD_STARTS_WITH_IGNORE_CASE, query.TextStartsWithIgnoreCase},
{"Contains", filter.TextFilterMethod_TEXT_FILTER_METHOD_CONTAINS, query.TextContains},
{"ContainsIgnoreCase", filter.TextFilterMethod_TEXT_FILTER_METHOD_CONTAINS_IGNORE_CASE, query.TextContainsIgnoreCase},
{"EndsWith", filter.TextFilterMethod_TEXT_FILTER_METHOD_ENDS_WITH, query.TextEndsWith},
{"EndsWithIgnoreCase", filter.TextFilterMethod_TEXT_FILTER_METHOD_ENDS_WITH_IGNORE_CASE, query.TextEndsWithIgnoreCase},
{"Unknown", filter.TextFilterMethod(999), -1},
}
for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := TextMethodPbToQuery(tc.input)
assert.Equal(t, tc.output, got)
})
}
}
func TestTimestampMethodPbToQuery(t *testing.T) {
t.Parallel()
tt := []struct {
name string
input filter.TimestampFilterMethod
output query.TimestampComparison
}{
{"Equals", filter.TimestampFilterMethod_TIMESTAMP_FILTER_METHOD_EQUALS, query.TimestampEquals},
{"Before", filter.TimestampFilterMethod_TIMESTAMP_FILTER_METHOD_BEFORE, query.TimestampLess},
{"After", filter.TimestampFilterMethod_TIMESTAMP_FILTER_METHOD_AFTER, query.TimestampGreater},
{"BeforeOrEquals", filter.TimestampFilterMethod_TIMESTAMP_FILTER_METHOD_BEFORE_OR_EQUALS, query.TimestampLessOrEquals},
{"AfterOrEquals", filter.TimestampFilterMethod_TIMESTAMP_FILTER_METHOD_AFTER_OR_EQUALS, query.TimestampGreaterOrEquals},
{"Unknown", filter.TimestampFilterMethod(999), -1},
}
for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := TimestampMethodPbToQuery(tc.input)
assert.Equal(t, tc.output, got)
})
}
}
func TestByteMethodPbToQuery(t *testing.T) {
t.Parallel()
tt := []struct {
name string
input filter.ByteFilterMethod
output query.BytesComparison
}{
{"Equals", filter.ByteFilterMethod_BYTE_FILTER_METHOD_EQUALS, query.BytesEquals},
{"NotEquals", filter.ByteFilterMethod_BYTE_FILTER_METHOD_NOT_EQUALS, query.BytesNotEquals},
{"Unknown", filter.ByteFilterMethod(999), -1},
}
for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := ByteMethodPbToQuery(tc.input)
assert.Equal(t, tc.output, got)
})
}
}
func TestPaginationPbToQuery(t *testing.T) {
t.Parallel()
defaults := systemdefaults.SystemDefaults{
DefaultQueryLimit: 10,
MaxQueryLimit: 100,
}
tt := []struct {
name string
query *filter.PaginationRequest
wantOff uint64
wantLim uint64
wantAsc bool
wantErr bool
}{
{
name: "nil query",
query: nil,
wantOff: 0,
wantLim: 10,
wantAsc: false,
wantErr: false,
},
{
name: "limit not set",
query: &filter.PaginationRequest{Offset: 5, Limit: 0, Asc: true},
wantOff: 5,
wantLim: 10,
wantAsc: true,
wantErr: false,
},
{
name: "limit set below max",
query: &filter.PaginationRequest{Offset: 2, Limit: 50, Asc: false},
wantOff: 2,
wantLim: 50,
wantAsc: false,
wantErr: false,
},
{
name: "limit exceeds max",
query: &filter.PaginationRequest{Offset: 1, Limit: 101, Asc: true},
wantOff: 0,
wantLim: 0,
wantAsc: false,
wantErr: true,
},
}
for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
off, lim, asc, err := PaginationPbToQuery(defaults, tc.query)
require.Equal(t, tc.wantErr, err != nil)
assert.Equal(t, tc.wantOff, off)
assert.Equal(t, tc.wantLim, lim)
assert.Equal(t, tc.wantAsc, asc)
})
}
}
func TestQueryToPaginationPb(t *testing.T) {
t.Parallel()
req := query.SearchRequest{Limit: 20}
resp := query.SearchResponse{Count: 123}
got := QueryToPaginationPb(req, resp)
assert.Equal(t, req.Limit, got.AppliedLimit)
assert.Equal(t, resp.Count, got.TotalResult)
}

View File

@@ -23,7 +23,7 @@ import (
func TestServer_Limits_AuditLogRetention(t *testing.T) {
isoInstance := integration.NewInstance(CTX)
iamOwnerCtx := isoInstance.WithAuthorization(CTX, integration.UserTypeIAMOwner)
iamOwnerCtx := isoInstance.WithAuthorizationToken(CTX, integration.UserTypeIAMOwner)
userID, projectID, appID, projectGrantID := seedObjects(iamOwnerCtx, t, isoInstance.Client)
beforeTime := time.Now()
farPast := timestamppb.New(beforeTime.Add(-10 * time.Hour).UTC())
@@ -37,7 +37,7 @@ func TestServer_Limits_AuditLogRetention(t *testing.T) {
}, "wait for added event assertions to pass")
_, err := integration.SystemClient().SetLimits(CTX, &system.SetLimitsRequest{
InstanceId: isoInstance.ID(),
AuditLogRetention: durationpb.New(time.Now().Sub(beforeTime)),
AuditLogRetention: durationpb.New(time.Since(beforeTime)),
})
require.NoError(t, err)
var limitedCounts *eventCounts

View File

@@ -4,6 +4,7 @@ package user_test
import (
"context"
"encoding/base64"
"errors"
"slices"
"testing"
@@ -16,6 +17,8 @@ import (
"github.com/zitadel/zitadel/internal/integration"
"github.com/zitadel/zitadel/pkg/grpc/feature/v2"
"github.com/zitadel/zitadel/pkg/grpc/filter/v2"
v2 "github.com/zitadel/zitadel/pkg/grpc/metadata/v2"
"github.com/zitadel/zitadel/pkg/grpc/object/v2"
"github.com/zitadel/zitadel/pkg/grpc/session/v2"
"github.com/zitadel/zitadel/pkg/grpc/user/v2"
@@ -630,6 +633,13 @@ func TestServer_ListUsers(t *testing.T) {
request.Queries = []*user.SearchQuery{}
request.Queries = append(request.Queries, OrganizationIdQuery(orgResp.OrganizationId))
request.Queries = append(request.Queries, InUserIDsQuery(infos.userIDs()))
Instance.SetUserMetadata(ctx, infos[0].UserID, "my meta", "my value 1")
Instance.SetUserMetadata(ctx, infos[1].UserID, "my meta 2", "my value 3")
Instance.SetUserMetadata(ctx, infos[2].UserID, "my meta", "my value 2")
request.Queries = append(request.Queries, MetadataKeyContainsQuery("my meta"))
request.SortingColumn = user.UserFieldName_USER_FIELD_NAME_CREATION_DATE
return infos
},
},
@@ -807,6 +817,15 @@ func TestServer_ListUsers(t *testing.T) {
request.Queries = []*user.SearchQuery{}
request.Queries = append(request.Queries, OrganizationIdQuery(orgResp.OrganizationId))
request.Queries = append(request.Queries, InUserEmailsQuery(infos.emails()))
Instance.SetUserMetadata(ctx, infos[0].UserID, "my meta 1", "my value")
Instance.SetUserMetadata(ctx, infos[0].UserID, "my meta 2", "my value")
Instance.SetUserMetadata(ctx, infos[1].UserID, "my meta 2", "my value")
Instance.SetUserMetadata(ctx, infos[2].UserID, "my meta", "my value")
request.Queries = append(request.Queries, MetadataValueQuery("my value"))
request.SortingColumn = user.UserFieldName_USER_FIELD_NAME_CREATION_DATE
return infos
},
},
@@ -1131,6 +1150,30 @@ func TestServer_ListUsers(t *testing.T) {
},
},
},
{
name: "when no users matching meta key should return empty list",
args: args{
IamCTX,
&user.ListUsersRequest{},
func(ctx context.Context, request *user.ListUsersRequest) userAttrs {
createUser(ctx, orgResp.OrganizationId, false)
request.Queries = []*user.SearchQuery{}
request.Queries = append(request.Queries, OrganizationIdQuery(orgResp.OrganizationId))
request.Queries = append(request.Queries, MetadataKeyContainsQuery("some non-existent meta"))
request.SortingColumn = user.UserFieldName_USER_FIELD_NAME_CREATION_DATE
return []userAttr{}
},
},
want: &user.ListUsersResponse{
Details: &object.ListDetails{
TotalResult: 0,
Timestamp: timestamppb.Now(),
},
SortingColumn: user.UserFieldName_USER_FIELD_NAME_CREATION_DATE,
Result: []*user.User{},
},
},
}
for _, f := range permissionCheckV2Settings {
for _, tc := range tt {
@@ -1324,3 +1367,44 @@ func OrganizationIdQuery(resourceowner string) *user.SearchQuery {
},
}
}
func OrQuery(queries []*user.SearchQuery) *user.SearchQuery {
return &user.SearchQuery{
Query: &user.SearchQuery_OrQuery{
OrQuery: &user.OrQuery{
Queries: queries,
},
},
}
}
func MetadataKeyContainsQuery(metadataKey string) *user.SearchQuery {
return &user.SearchQuery{
Query: &user.SearchQuery_MetadataKeyFilter{
MetadataKeyFilter: &v2.MetadataKeyFilter{
Key: metadataKey,
Method: filter.TextFilterMethod_TEXT_FILTER_METHOD_STARTS_WITH},
},
}
}
func MetakeyEqualsQuery(metaKey string) *user.SearchQuery {
return &user.SearchQuery{
Query: &user.SearchQuery_MetadataKeyFilter{
MetadataKeyFilter: &v2.MetadataKeyFilter{
Key: metaKey,
Method: filter.TextFilterMethod_TEXT_FILTER_METHOD_EQUALS},
},
}
}
func MetadataValueQuery(metaValue string) *user.SearchQuery {
return &user.SearchQuery{
Query: &user.SearchQuery_MetadataValueFilter{
MetadataValueFilter: &v2.MetadataValueFilter{
Value: []byte(base64.StdEncoding.EncodeToString([]byte(metaValue))),
Method: filter.ByteFilterMethod_BYTE_FILTER_METHOD_EQUALS,
},
},
}
}

View File

@@ -44,11 +44,11 @@ func TestMain(m *testing.M) {
Instance = integration.NewInstance(ctx)
UserCTX = Instance.WithAuthorization(ctx, integration.UserTypeNoPermission)
IamCTX = Instance.WithAuthorization(ctx, integration.UserTypeIAMOwner)
LoginCTX = Instance.WithAuthorization(ctx, integration.UserTypeLogin)
UserCTX = Instance.WithAuthorizationToken(ctx, integration.UserTypeNoPermission)
IamCTX = Instance.WithAuthorizationToken(ctx, integration.UserTypeIAMOwner)
LoginCTX = Instance.WithAuthorizationToken(ctx, integration.UserTypeLogin)
SystemCTX = integration.WithSystemAuthorization(ctx)
CTX = Instance.WithAuthorization(ctx, integration.UserTypeOrgOwner)
CTX = Instance.WithAuthorizationToken(ctx, integration.UserTypeOrgOwner)
Client = Instance.Client.UserV2beta
return m.Run()
}())

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

View File

@@ -61,6 +61,21 @@ machine_jwt_profile_grant_single_user: ensure_modules ensure_key_pair bundle
cd ../../xk6-modules && xk6 build --with xk6-zitadel=.
${K6} run --summary-trend-stats "min,avg,max,p(50),p(95),p(99)" dist/machine_jwt_profile_grant_single_user.js --vus ${VUS} --duration ${DURATION} --out csv=output/machine_jwt_profile_grant_single_user_${DATE}.csv
.PHONY: verify_all_user_grants_exist
verify_all_user_grants_exist: ensure_modules bundle
${K6} run --summary-trend-stats "min,avg,max,p(50),p(95),p(99)" dist/verify_all_user_grants_exist.js --vus ${VUS} --duration ${DURATION}
# --out csv=output/verify_all_user_grants_exist_${DATE}.csv
.PHONY: users_by_metadata_key
users_by_metadata_key: ensure_modules bundle
${K6} run --summary-trend-stats "min,avg,max,p(50),p(95),p(99)" dist/users_by_metadata_key.js --vus ${VUS} --duration ${DURATION}
# --out csv=output/users_by_metadata_${DATE}.csv
.PHONY: users_by_metadata_value
users_by_metadata_value: ensure_modules bundle
${K6} run --summary-trend-stats "min,avg,max,p(50),p(95),p(99)" dist/users_by_metadata_value.js --vus ${VUS} --duration ${DURATION}
# --out csv=output/users_by_metadata_${DATE}.csv
.PHONY: lint
lint:
npm i

View File

@@ -14,7 +14,7 @@
"@babel/plugin-proposal-object-rest-spread": "7.13.8",
"@babel/preset-env": "7.23.8",
"@babel/preset-typescript": "7.23.3",
"@types/k6": ">=0.50.0",
"@types/k6": ">=1.2.0",
"@types/webpack": "5.28.5",
"babel-loader": "9.1.3",
"clean-webpack-plugin": "4.0.0",
@@ -1880,10 +1880,11 @@
"dev": true
},
"node_modules/@types/k6": {
"version": "0.54.1",
"resolved": "https://registry.npmjs.org/@types/k6/-/k6-0.54.1.tgz",
"integrity": "sha512-ezTMhuWr3TZIMOqAv51/4rD5T4FE1pSryrh5BCgxsniuqxbi5jQ3YEiOlO9C1+LvJcliC2byyd2Cw6cnUY7CLg==",
"dev": true
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@types/k6/-/k6-1.2.0.tgz",
"integrity": "sha512-7F/vjekOUCzFi5AY+yYBOL38iEtN9EXufdKOAMFkJsdLryJ3/bB/W9jppED1FO5o7zeaPf8M46ardYPD8xgy/w==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/minimatch": {
"version": "5.1.2",

View File

@@ -13,7 +13,7 @@
"@babel/plugin-proposal-object-rest-spread": "7.13.8",
"@babel/preset-env": "7.23.8",
"@babel/preset-typescript": "7.23.3",
"@types/k6": ">=0.50.0",
"@types/k6": ">=1.2.0",
"@types/webpack": "5.28.5",
"babel-loader": "9.1.3",
"clean-webpack-plugin": "4.0.0",

View File

@@ -86,7 +86,7 @@ function enterPassword(page: Response, user: User): Response {
}
check(response, {
'password status ok': (r) => r.status >= 200 && r.status < 300 || fail('enter password failed'),
'password status ok': (r) => (r.status >= 200 && r.status < 300) || fail('enter password failed'),
'password callback': (r) =>
r.url.startsWith(url('/ui/console/auth/callback?code=')) || fail(`wrong password callback: ${r.url}`),
});
@@ -117,7 +117,8 @@ function token(code = '') {
tokenTrend.add(response.timings.duration);
check(response, {
'token status ok': (r) => r.status >= 200 && r.status < 300 || fail(`invalid token response status: ${r.status} body: ${r.body}`),
'token status ok': (r) =>
(r.status >= 200 && r.status < 300) || fail(`invalid token response status: ${r.status} body: ${r.body}`),
});
const token = new Tokens(response.json() as JSONObject);
check(token, {

View File

@@ -19,7 +19,8 @@ export async function addIAMMember(userId: string, roles: string[], accessToken:
},
);
check(res, {
'member added successful': (r) => r.status >= 200 && r.status < 300 || fail(`unable add member: ${JSON.stringify(res)}`),
'member added successful': (r) =>
(r.status >= 200 && r.status < 300) || fail(`unable add member: ${JSON.stringify(res)}`),
});
addIAMMemberTrend.add(res.timings.duration);
}

View File

@@ -0,0 +1,4 @@
export type Metadata = {
key: string;
value: string;
};

View File

@@ -30,7 +30,7 @@ function configuration() {
const res = http.get(url('/.well-known/openid-configuration'));
check(res, {
'openid configuration': (r) => r.status >= 200 && r.status < 300 || fail('unable to load openid configuration'),
'openid configuration': (r) => (r.status >= 200 && r.status < 300) || fail('unable to load openid configuration'),
});
oidcConfig = res.json();
@@ -176,7 +176,8 @@ export async function authRequestByID(id: string, tokens: any): Promise<Response
},
});
check(response, {
'authorize status ok': (r) => r.status >= 200 && r.status < 300 || fail(`auth request by failed: ${JSON.stringify(r)}`),
'authorize status ok': (r) =>
(r.status >= 200 && r.status < 300) || fail(`auth request by failed: ${JSON.stringify(r)}`),
});
authRequestByIDTrend.add(response.timings.duration);
return response;
@@ -202,7 +203,8 @@ export async function finalizeAuthRequest(id: string, session: any, tokens: any)
},
);
check(res, {
'finalize auth request status ok': (r) => r.status >= 200 && r.status < 300 || fail(`finalize auth request failed: ${JSON.stringify(r)}`),
'finalize auth request status ok': (r) =>
(r.status >= 200 && r.status < 300) || fail(`finalize auth request failed: ${JSON.stringify(r)}`),
});
finalizeAuthRequestTrend.add(res.timings.duration);

View File

@@ -0,0 +1,65 @@
import { loginByUsernamePassword } from '../login_ui';
import { createOrg, removeOrg } from '../org';
import { createHuman, User, createMachine, setUserMetadata, listUsers } from '../user';
import { Config } from '../config';
import { check } from 'k6';
import encoding from 'k6/encoding';
const userAmount = parseInt(__ENV.USER_AMOUNT) || 2500;
export async function setup() {
const tokens = loginByUsernamePassword(Config.admin as User);
console.info('setup: admin signed in');
const org = await createOrg(tokens.accessToken!);
console.info(`setup: org (${org.organizationId}) created`);
const users: User[] = [];
await Promise.all(
Array.from({ length: userAmount }, async (_, i) => {
let user: User;
let type: 'human' | 'machine';
if (i % 2 === 0) {
user = await createHuman(`zitizen-${i}`, org, tokens.accessToken!);
type = 'human';
} else {
user = await createMachine(`zitachine-${i}`, org, tokens.accessToken!);
type = 'machine';
}
users.push(user);
await setUserMetadata(
[
{ key: 'type', value: encoding.b64encode(type, 'rawurl') },
{ key: 'org', value: encoding.b64encode(org.organizationId, 'rawurl') },
{ key: 'id', value: encoding.b64encode(user.userId, 'rawurl') },
],
user.userId,
tokens.accessToken!,
);
if (i % 10 === 0) {
console.log(`setup: ${i} of ${userAmount} users setup`);
}
}),
);
console.info(`setup: ${users.length} users created`);
return { tokens, org, users };
}
export default async function (data: any) {
const result = await listUsers(
{
queries: [{ metadataKeyFilter: { key: 'org', method: 'TEXT_FILTER_METHOD_EQUALS' } }],
},
data.tokens.accessToken!,
);
check(result, {
'total result length': (res) => res.details.totalResult == userAmount,
}) || console.log(`unexpected amount of users. expected ${userAmount} but got ${result.details.totalResult}`);
}
export function teardown(data: any) {
removeOrg(data.org, data.tokens.accessToken);
console.info('teardown: org removed');
}

View File

@@ -0,0 +1,67 @@
import { loginByUsernamePassword } from '../login_ui';
import { createOrg, removeOrg } from '../org';
import { createHuman, User, createMachine, setUserMetadata, listUsers } from '../user';
import { Config } from '../config';
import { check } from 'k6';
import encoding from 'k6/encoding';
const userAmount = parseInt(__ENV.USER_AMOUNT) || 2500;
export async function setup() {
const tokens = loginByUsernamePassword(Config.admin as User);
console.info('setup: admin signed in');
const org = await createOrg(tokens.accessToken!);
console.info(`setup: org (${org.organizationId}) created`);
const users: User[] = [];
await Promise.all(
Array.from({ length: userAmount }, async (_, i) => {
let user: User;
let type: 'human' | 'machine';
if (i % 2 === 0) {
user = await createHuman(`zitizen-${i}`, org, tokens.accessToken!);
type = 'human';
} else {
user = await createMachine(`zitachine-${i}`, org, tokens.accessToken!);
type = 'machine';
}
users.push(user);
await setUserMetadata(
[
{ key: 'type', value: encoding.b64encode(type, 'rawurl') },
{ key: 'org', value: encoding.b64encode(org.organizationId, 'rawurl') },
{ key: 'id', value: encoding.b64encode(user.userId, 'rawurl') },
],
user.userId,
tokens.accessToken!,
);
if (i % 10 === 0) {
console.log(`setup: ${i} of ${userAmount} users setup`);
}
}),
);
console.info(`setup: ${users.length} users created`);
return { tokens, org, users };
}
export default async function (data: any) {
const result = await listUsers(
{
queries: [
{ metadataValueFilter: { value: encoding.b64encode('human', 'rawurl'), method: 'BYTE_FILTER_METHOD_EQUALS' } },
],
},
data.tokens.accessToken!,
);
check(result, {
'total result length': (res) => res.details.totalResult == userAmount / 2,
}) || console.log(`unexpected amount of users. expected ${userAmount / 2} but got ${result.details.totalResult}`);
}
export function teardown(data: any) {
removeOrg(data.org, data.tokens.accessToken);
console.info('teardown: org removed');
}

View File

@@ -3,6 +3,7 @@ import { Org } from './org';
import http, { RefinedResponse } from 'k6/http';
import url from './url';
import { check } from 'k6';
import { Metadata } from './metadata';
export type User = {
userId: string;
@@ -298,3 +299,70 @@ export function deleteUser(userId: string, org: Org, accessToken: string): Promi
});
});
}
const setUserMetadataTrend = new Trend('set_user_metadata_duration', true);
export function setUserMetadata(metadata: Metadata[], userId: string, accessToken: string): Promise<RefinedResponse<any>> {
return new Promise((resolve, reject) => {
let response = http.asyncRequest('POST', url(`/v2/users/${userId}/metadata`), JSON.stringify({ metadata: metadata }), {
headers: {
authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
});
response
.then((res) => {
check(res, {
'set user metadata is status ok': (r) => r.status >= 200 && r.status < 300,
}) || console.log(`unable to set user metadata (user id: ${userId}) status: ${res.status} body: ${res.body}`);
setUserMetadataTrend.add(res.timings.duration);
resolve(res);
})
.catch((reason) => {
reject(reason);
});
});
}
export type ListUsersRequest = {
queries: {
metadataKeyFilter?: {
key: string;
method: 'TEXT_FILTER_METHOD_EQUALS' | 'TEXT_FILTER_METHOD_CONTAINS' | 'TEXT_FILTER_METHOD_CONTAINS_IGNORE_CASE';
};
metadataValueFilter?: {
value: string;
method: 'BYTE_FILTER_METHOD_EQUALS' | 'BYTE_FILTER_METHOD_NOT_EQUALS';
};
}[];
};
export type ListUsersResult = {
details: {
totalResult: number;
};
};
const listUsersTrend = new Trend('list_users_duration', true);
export function listUsers(body: ListUsersRequest, accessToken: string): Promise<ListUsersResult> {
return new Promise((resolve, reject) => {
let response = http.asyncRequest('POST', url(`/v2/users`), JSON.stringify(body), {
headers: {
authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
});
response
.then((res) => {
check(res, {
'list users is status ok': (r) => r.status >= 200 && r.status < 300,
}) || console.log(`unable to list users status: ${res.status} body: ${res.body}`);
listUsersTrend.add(res.timings.duration);
resolve(res.json()! as ListUsersResult);
})
.catch((reason) => {
reject(reason);
});
});
}

View File

@@ -0,0 +1,46 @@
import http from 'k6/http';
import { Trend } from 'k6/metrics';
import url from './url';
import { check, fail } from 'k6';
import { Project } from './project';
import { Org } from './org';
export type UserGrant = {
userGrantId: string;
};
const addUserGrantTrend = new Trend('user_grant_add', true);
export async function addUserGrant(
org: Org,
userId: string,
project: Project,
roles: string[],
accessToken: string,
): Promise<UserGrant> {
return new Promise((resolve, reject) => {
let response = http.asyncRequest(
'POST',
url(`/management/v1/users/${userId}/grants`),
JSON.stringify({
projectId: project.id,
roleKeys: roles,
}),
{
headers: {
authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
'x-zitadel-orgid': org.organizationId,
},
},
);
response.then((res) => {
check(res, {
'add User Grant status ok': (r) => r.status >= 200 && r.status < 300,
}) || reject(`unable to add User Grant status: ${res.status} body: ${res.body}`);
addUserGrantTrend.add(res.timings.duration);
resolve(res.json() as UserGrant);
});
});
}

View File

@@ -1065,6 +1065,7 @@ service UserService {
};
};
}
}
message AddHumanUserRequest{
@@ -1947,4 +1948,4 @@ enum AuthenticationMethodType {
AUTHENTICATION_METHOD_TYPE_U2F = 5;
AUTHENTICATION_METHOD_TYPE_OTP_SMS = 6;
AUTHENTICATION_METHOD_TYPE_OTP_EMAIL = 7;
}
}