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