From 4b6add514f995bbaf88238ed49468e21f7f3c8be Mon Sep 17 00:00:00 2001 From: Livio Spring Date: Mon, 12 Jan 2026 07:22:34 +0100 Subject: [PATCH] fix(api): implement missing filters on ListAdministrators endpoint (#11319) # Which Problems Are Solved A customer reported that multiple filters on the ListAdministrators endpoint were returning a `List.Query.Invalid` error. They were missing the implementation. # How the Problems Are Solved Implement `and`, `or`, `not` and `role` filters. # Additional Changes None # Additional Context - reported by customer - requires backport to v4.x --- .../v2/integration_test/query_test.go | 112 +++++++++++++++++- .../api/grpc/internal_permission/v2/query.go | 48 +++++++- .../grpc/internal_permission/v2/query_test.go | 103 ++++++++++++++++ internal/query/user_membership.go | 4 + 4 files changed, 261 insertions(+), 6 deletions(-) create mode 100644 internal/api/grpc/internal_permission/v2/query_test.go diff --git a/internal/api/grpc/internal_permission/v2/integration_test/query_test.go b/internal/api/grpc/internal_permission/v2/integration_test/query_test.go index 522979f84..cc033f203 100644 --- a/internal/api/grpc/internal_permission/v2/integration_test/query_test.go +++ b/internal/api/grpc/internal_permission/v2/integration_test/query_test.go @@ -584,6 +584,117 @@ func TestServer_ListAdministrators(t *testing.T) { Administrators: []*internal_permission.Administrator{projectGrantAdministrator1, projectGrantAdministrator2}, }, }, + { + name: "list, role filter", + args: args{ + ctx: iamOwnerCtx, + req: &internal_permission.ListAdministratorsRequest{ + Filters: []*internal_permission.AdministratorSearchFilter{ + { + Filter: &internal_permission.AdministratorSearchFilter_Role{ + Role: &internal_permission.RoleFilter{ + RoleKey: "PROJECT_OWNER", + }, + }, + }, + }, + Pagination: &filter.PaginationRequest{Asc: true, Limit: 2}, + }, + }, + want: &internal_permission.ListAdministratorsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 4, // 3 project admins with PROJECT_OWNER role and the project creator + AppliedLimit: 2, + }, + Administrators: []*internal_permission.Administrator{projectAdministrator1, projectAdministrator2}, + }, + }, + { + name: "list, and with not filter", + args: args{ + ctx: iamOwnerCtx, + req: &internal_permission.ListAdministratorsRequest{ + Filters: []*internal_permission.AdministratorSearchFilter{ + { + Filter: &internal_permission.AdministratorSearchFilter_And{ + And: &internal_permission.AndFilter{ + Queries: []*internal_permission.AdministratorSearchFilter{ + { + Filter: &internal_permission.AdministratorSearchFilter_Role{ + Role: &internal_permission.RoleFilter{ + RoleKey: "PROJECT_OWNER", + }, + }, + }, + { + Filter: &internal_permission.AdministratorSearchFilter_Not{ + Not: &internal_permission.NotFilter{ + Query: &internal_permission.AdministratorSearchFilter{ + Filter: &internal_permission.AdministratorSearchFilter_InUserIdsFilter{ + InUserIdsFilter: &filter.InIDsFilter{ + Ids: []string{projectAdministrator1.GetUser().GetId(), projectAdministrator2.GetUser().GetId()}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + Pagination: &filter.PaginationRequest{Asc: true, Limit: 1}, + }, + }, + want: &internal_permission.ListAdministratorsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 2, // 1 project admin and the project creator + AppliedLimit: 1, + }, + Administrators: []*internal_permission.Administrator{projectAdministrator3}, + }, + }, + { + name: "list, or filter", + args: args{ + ctx: iamOwnerCtx, + req: &internal_permission.ListAdministratorsRequest{ + Filters: []*internal_permission.AdministratorSearchFilter{ + { + Filter: &internal_permission.AdministratorSearchFilter_Or{ + Or: &internal_permission.OrFilter{ + Queries: []*internal_permission.AdministratorSearchFilter{ + { + Filter: &internal_permission.AdministratorSearchFilter_InUserIdsFilter{ + InUserIdsFilter: &filter.InIDsFilter{ + Ids: []string{projectGrantAdministrator1.GetUser().GetId()}, + }, + }, + }, + { + Filter: &internal_permission.AdministratorSearchFilter_Role{ + Role: &internal_permission.RoleFilter{ + RoleKey: "PROJECT_OWNER", + }, + }, + }, + }, + }, + }, + }, + }, + Pagination: &filter.PaginationRequest{Asc: true, Limit: 2}, + }, + }, + want: &internal_permission.ListAdministratorsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 5, // 3 project grant admins, the project creator and the project grant admin + AppliedLimit: 2, + }, + Administrators: []*internal_permission.Administrator{projectAdministrator1, projectAdministrator2}, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -766,7 +877,6 @@ func TestServer_ListAdministrators_PermissionV2(t *testing.T) { instancePermissionV2.CreateProjectGrantMembership(t, iamOwnerCtx, projectResp.GetId(), grantedOrganizationResp.GetOrganizationId(), userProjectGrantResp.GetUserId()) patProjectGrantResp := instancePermissionV2.CreatePersonalAccessToken(iamOwnerCtx, userProjectGrantResp.GetUserId()) projectGrantOwnerCtx := integration.WithAuthorizationToken(CTX, patProjectGrantResp.Token) - type args struct { ctx context.Context dep func(*internal_permission.ListAdministratorsRequest, *internal_permission.ListAdministratorsResponse) diff --git a/internal/api/grpc/internal_permission/v2/query.go b/internal/api/grpc/internal_permission/v2/query.go index c90593813..2fce17d2a 100644 --- a/internal/api/grpc/internal_permission/v2/query.go +++ b/internal/api/grpc/internal_permission/v2/query.go @@ -33,7 +33,7 @@ func (s *Server) listAdministratorsRequestToModel(req *internal_permission.ListA if err != nil { return nil, err } - queries, err := administratorSearchFiltersToQuery(req.Filters) + queries, err := administratorSearchFiltersToQuery(req.Filters, 0) // start at level 0 if err != nil { return nil, err } @@ -63,10 +63,10 @@ func administratorFieldNameToSortingColumn(field internal_permission.Administrat } } -func administratorSearchFiltersToQuery(queries []*internal_permission.AdministratorSearchFilter) (_ []query.SearchQuery, err error) { +func administratorSearchFiltersToQuery(queries []*internal_permission.AdministratorSearchFilter, level uint8) (_ []query.SearchQuery, err error) { q := make([]query.SearchQuery, len(queries)) for i, qry := range queries { - q[i], err = administratorFilterToModel(qry) + q[i], err = administratorFilterToQuery(qry, level) if err != nil { return nil, err } @@ -74,7 +74,11 @@ func administratorSearchFiltersToQuery(queries []*internal_permission.Administra return q, nil } -func administratorFilterToModel(filter *internal_permission.AdministratorSearchFilter) (query.SearchQuery, error) { +func administratorFilterToQuery(filter *internal_permission.AdministratorSearchFilter, level uint8) (query.SearchQuery, error) { + if level > 20 { + // can't go deeper than 20 levels of nesting. + return nil, zerrors.ThrowInvalidArgument(nil, "PERM-zsQ97", "Errors.Query.TooManyNestingLevels") + } switch q := filter.Filter.(type) { case *internal_permission.AdministratorSearchFilter_InUserIdsFilter: return inUserIDsFilterToQuery(q.InUserIdsFilter) @@ -90,8 +94,16 @@ func administratorFilterToModel(filter *internal_permission.AdministratorSearchF return userDisplayNameFilterToQuery(q.UserDisplayName) case *internal_permission.AdministratorSearchFilter_Resource: return resourceFilterToQuery(q.Resource) + case *internal_permission.AdministratorSearchFilter_Role: + return roleFilterToQuery(q.Role) + case *internal_permission.AdministratorSearchFilter_And: + return andQueryToQuery(q.And, level) + case *internal_permission.AdministratorSearchFilter_Or: + return orQueryToQuery(q.Or, level) + case *internal_permission.AdministratorSearchFilter_Not: + return notQueryToQuery(q.Not, level) default: - return nil, zerrors.ThrowInvalidArgument(nil, "ORG-vR9nC", "List.Query.Invalid") + return nil, zerrors.ThrowInvalidArgument(nil, "PERM-vR9nC", "List.Query.Invalid") } } @@ -151,6 +163,32 @@ func resourceFilterToQuery(q *internal_permission.ResourceFilter) (query.SearchQ return nil, nil } +func roleFilterToQuery(q *internal_permission.RoleFilter) (query.SearchQuery, error) { + return query.NewMembershipRoleQuery(q.GetRoleKey()) +} + +func orQueryToQuery(q *internal_permission.OrFilter, level uint8) (query.SearchQuery, error) { + mappedQueries, err := administratorSearchFiltersToQuery(q.Queries, level+1) + if err != nil { + return nil, err + } + return query.NewOrQuery(mappedQueries...) +} +func andQueryToQuery(q *internal_permission.AndFilter, level uint8) (query.SearchQuery, error) { + mappedQueries, err := administratorSearchFiltersToQuery(q.Queries, level+1) + if err != nil { + return nil, err + } + return query.NewAndQuery(mappedQueries...) +} +func notQueryToQuery(q *internal_permission.NotFilter, level uint8) (query.SearchQuery, error) { + mappedQuery, err := administratorFilterToQuery(q.Query, level+1) + if err != nil { + return nil, err + } + return query.NewNotQuery(mappedQuery) +} + func administratorsToPb(administrators []*query.Administrator) []*internal_permission.Administrator { a := make([]*internal_permission.Administrator, len(administrators)) for i, admin := range administrators { diff --git a/internal/api/grpc/internal_permission/v2/query_test.go b/internal/api/grpc/internal_permission/v2/query_test.go new file mode 100644 index 000000000..a19820fb9 --- /dev/null +++ b/internal/api/grpc/internal_permission/v2/query_test.go @@ -0,0 +1,103 @@ +package internal_permission + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/internal/zerrors" + "github.com/zitadel/zitadel/pkg/grpc/filter/v2" + "github.com/zitadel/zitadel/pkg/grpc/internal_permission/v2" +) + +func Test_administratorFilterToQuery(t *testing.T) { + now := time.Now().UTC() + type args struct { + filter *internal_permission.AdministratorSearchFilter + level uint8 + } + tests := []struct { + name string + args args + want query.SearchQuery + wantErr error + }{ + { + name: "max nested queries", + args: args{ + filter: &internal_permission.AdministratorSearchFilter{ + Filter: &internal_permission.AdministratorSearchFilter_And{ + And: &internal_permission.AndFilter{ + Queries: []*internal_permission.AdministratorSearchFilter{ + { + Filter: &internal_permission.AdministratorSearchFilter_CreationDate{ + CreationDate: &filter.TimestampFilter{ + Timestamp: timestamppb.New(now), + }, + }, + }, + }, + }, + }, + }, + level: 19, + }, + want: func() query.SearchQuery { + dateQuery, _ := query.NewMembershipCreationDateQuery(now, query.TimestampEquals) + q, _ := query.NewAndQuery(dateQuery) + return q + }(), + }, + { + name: "too many nested queries", + args: args{ + filter: &internal_permission.AdministratorSearchFilter{ + Filter: &internal_permission.AdministratorSearchFilter_And{ + And: &internal_permission.AndFilter{ + Queries: []*internal_permission.AdministratorSearchFilter{ + { + Filter: &internal_permission.AdministratorSearchFilter_And{ + And: &internal_permission.AndFilter{ + Queries: []*internal_permission.AdministratorSearchFilter{ + { + Filter: &internal_permission.AdministratorSearchFilter_CreationDate{ + CreationDate: &filter.TimestampFilter{ + Timestamp: timestamppb.New(time.Now().UTC()), + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + level: 19, + }, + wantErr: zerrors.ThrowInvalidArgument(nil, "PERM-zsQ97", "Errors.Query.TooManyNestingLevels"), + }, + { + name: "invalid filter", + args: args{ + filter: &internal_permission.AdministratorSearchFilter{ + Filter: nil, + }, + level: 0, + }, + wantErr: zerrors.ThrowInvalidArgument(nil, "PERM-vR9nC", "List.Query.Invalid"), + }, + // rest is tested in integration tests + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := administratorFilterToQuery(tt.args.filter, tt.args.level) + assert.Equal(t, tt.want, got) + assert.ErrorIs(t, err, tt.wantErr) + }) + } +} diff --git a/internal/query/user_membership.go b/internal/query/user_membership.go index e18a4cc8b..5f087a548 100644 --- a/internal/query/user_membership.go +++ b/internal/query/user_membership.go @@ -106,6 +106,10 @@ func NewMembershipIsIAMQuery() (SearchQuery, error) { return NewNotNullQuery(InstanceMemberIAMID) } +func NewMembershipRoleQuery(role string) (SearchQuery, error) { + return NewTextQuery(membershipRoles, role, TextListContains) +} + func (q *MembershipSearchQuery) toQuery(query sq.SelectBuilder) sq.SelectBuilder { query = q.SearchRequest.toQuery(query) for _, q := range q.Queries {