mirror of
https://github.com/zitadel/zitadel.git
synced 2025-12-06 03:52:05 +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,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 {
|
||||
|
||||
164
internal/api/grpc/filter/v2/converter_test.go
Normal file
164
internal/api/grpc/filter/v2/converter_test.go
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}())
|
||||
|
||||
Reference in New Issue
Block a user