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

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