From ad8e8bf61f72b51d27230586e4bab0aa0391ef15 Mon Sep 17 00:00:00 2001 From: Gayathri Vijayan <66356931+grvijayan@users.noreply.github.com> Date: Tue, 28 Oct 2025 14:23:54 +0100 Subject: [PATCH] feat(group): manage users in user groups (#10940) # Which Problems Are Solved 1. Adding users to user groups and removing users from user groups. 2. Searching for users in user groups by group IDs or user IDs # How the Problems Are Solved By adding: 1. The API definitions to manage users in users groups 3. The command-layer implementation of adding users/removing users to/from user groups. 4. The projection table group_users1 5. Query-side implementation to search for users in user groups # Additional Changes 1. Remove debug statements from unit tests. 2. Fix removal of groups when orgs are removed 3. Add unit tests for groups projection # Additional Context * Related to #9702 * Follow-up for PRs * https://github.com/zitadel/zitadel/pull/10455 * https://github.com/zitadel/zitadel/pull/10758 * https://github.com/zitadel/zitadel/pull/10853 --- cmd/defaults.yaml | 24 + internal/api/grpc/auth/user.go | 30 +- internal/api/grpc/group/v2/group_users.go | 30 + .../group/v2/integration_test/group_test.go | 54 +- .../v2/integration_test/group_users_test.go | 323 +++++++++ .../group/v2/integration_test/query_test.go | 647 ++++++++++++++++++ internal/api/grpc/group/v2/query.go | 99 +++ internal/api/grpc/group/v2/query_test.go | 305 ++++++++- internal/api/grpc/management/user.go | 44 +- internal/api/grpc/user/v2/user.go | 44 +- internal/api/grpc/user/v2beta/user.go | 44 +- internal/api/scim/resources/user.go | 44 +- internal/command/group.go | 21 +- internal/command/group_model.go | 34 +- internal/command/group_users.go | 131 ++++ internal/command/group_users_test.go | 541 +++++++++++++++ internal/command/permission_checks.go | 8 + internal/command/user.go | 11 +- internal/command/user_test.go | 78 ++- internal/command/user_v2.go | 11 +- internal/command/user_v2_test.go | 79 ++- internal/domain/permission.go | 3 + internal/integration/client.go | 9 + internal/query/group_users.go | 220 ++++++ internal/query/group_users_test.go | 266 +++++++ internal/query/projection/group.go | 4 +- internal/query/projection/group_test.go | 213 ++++++ internal/query/projection/group_users.go | 167 +++++ internal/query/projection/group_users_test.go | 177 +++++ internal/query/projection/projection.go | 5 +- internal/repository/group/eventstore.go | 2 + internal/repository/group/user.go | 74 ++ proto/zitadel/group/v2/group.proto | 45 ++ proto/zitadel/group/v2/group_service.proto | 126 +++- 34 files changed, 3825 insertions(+), 88 deletions(-) create mode 100644 internal/api/grpc/group/v2/group_users.go create mode 100644 internal/api/grpc/group/v2/integration_test/group_users_test.go create mode 100644 internal/command/group_users.go create mode 100644 internal/command/group_users_test.go create mode 100644 internal/query/group_users.go create mode 100644 internal/query/group_users_test.go create mode 100644 internal/query/projection/group_test.go create mode 100644 internal/query/projection/group_users.go create mode 100644 internal/query/projection/group_users_test.go create mode 100644 internal/repository/group/user.go diff --git a/cmd/defaults.yaml b/cmd/defaults.yaml index e5d0f10f4ac..d9e42f58f62 100644 --- a/cmd/defaults.yaml +++ b/cmd/defaults.yaml @@ -1383,6 +1383,9 @@ InternalAuthZ: - "group.write" - "group.read" - "group.delete" + - "group.user.write" + - "group.user.read" + - "group.user.delete" - Role: "IAM_OWNER_VIEWER" Permissions: - "iam.read" @@ -1420,6 +1423,7 @@ InternalAuthZ: - "userschema.read" - "session.read" - "group.read" + - "group.user.read" - Role: "IAM_ORG_MANAGER" Permissions: - "org.read" @@ -1483,6 +1487,9 @@ InternalAuthZ: - "group.write" - "group.read" - "group.delete" + - "group.user.write" + - "group.user.read" + - "group.user.delete" - Role: "IAM_USER_MANAGER" Permissions: - "org.read" @@ -1512,6 +1519,7 @@ InternalAuthZ: - "session.read" - "session.delete" - "group.read" + - "group.user.read" - Role: "IAM_ADMIN_IMPERSONATOR" Permissions: - "admin.impersonation" @@ -1580,6 +1588,9 @@ InternalAuthZ: - "group.write" - "group.read" - "group.delete" + - "group.user.write" + - "group.user.read" + - "group.user.delete" - Role: "IAM_LOGIN_CLIENT" Permissions: - "iam.read" @@ -1619,6 +1630,7 @@ InternalAuthZ: - "session.delete" - "userschema.read" - "group.read" + - "group.user.read" - Role: "ORG_USER_MANAGER" Permissions: - "org.read" @@ -1639,6 +1651,7 @@ InternalAuthZ: - "session.read" - "session.delete" - "group.read" + - "group.user.read" - Role: "ORG_OWNER_VIEWER" Permissions: - "org.read" @@ -1661,6 +1674,7 @@ InternalAuthZ: - "project.grant.member.read" - "project.grant.user.grant.read" - "group.read" + - "group.user.read" - Role: "ORG_SETTINGS_MANAGER" Permissions: - "org.read" @@ -1692,6 +1706,7 @@ InternalAuthZ: - "project.grant.read" - "project.grant.member.read" - "group.read" + - "group.user.read" - Role: "ORG_PROJECT_PERMISSION_EDITOR" Permissions: - "org.read" @@ -1961,6 +1976,9 @@ SystemAuthZ: - "group.write" - "group.read" - "group.delete" + - "group.user.write" + - "group.user.read" + - "group.user.delete" - Role: "IAM_OWNER_VIEWER" Permissions: - "iam.read" @@ -1998,6 +2016,7 @@ SystemAuthZ: - "userschema.read" - "session.read" - "group.read" + - "group.user.read" - Role: "IAM_ORG_MANAGER" Permissions: - "org.read" @@ -2061,6 +2080,9 @@ SystemAuthZ: - "group.write" - "group.read" - "group.delete" + - "group.user.write" + - "group.user.read" + - "group.user.delete" - Role: "IAM_USER_MANAGER" Permissions: - "org.read" @@ -2090,6 +2112,7 @@ SystemAuthZ: - "session.read" - "session.delete" - "group.read" + - "group.user.read" - Role: "IAM_ADMIN_IMPERSONATOR" Permissions: - "admin.impersonation" @@ -2136,6 +2159,7 @@ SystemAuthZ: - "session.delete" - "userschema.read" - "group.read" + - "group.user.read" # If a new projection is introduced it will be prefilled during the setup process (if enabled) # This can prevent serving outdated data after a version upgrade, but might require a longer setup / upgrade process: diff --git a/internal/api/grpc/auth/user.go b/internal/api/grpc/auth/user.go index 4015b0d370f..0270834aa72 100644 --- a/internal/api/grpc/auth/user.go +++ b/internal/api/grpc/auth/user.go @@ -47,7 +47,11 @@ func (s *Server) RemoveMyUser(ctx context.Context, _ *auth_pb.RemoveMyUserReques if err != nil { return nil, err } - details, err := s.command.RemoveUser(ctx, ctxData.UserID, ctxData.ResourceOwner, cascadingMemberships(memberships.Memberships), userGrantsToIDs(grants.UserGrants)...) + groupIDs, err := s.getGroupsByUserID(ctx, ctxData.UserID) + if err != nil { + return nil, err + } + details, err := s.command.RemoveUser(ctx, ctxData.UserID, ctxData.ResourceOwner, cascadingMemberships(memberships.Memberships), userGrantsToIDs(grants.UserGrants), groupIDs) if err != nil { return nil, err } @@ -321,3 +325,27 @@ func userGrantsToIDs(userGrants []*query.UserGrant) []string { } return converted } + +func (s *Server) getGroupsByUserID(ctx context.Context, userID string) ([]string, error) { + groupUserQuery, err := query.NewGroupUsersUserIDsSearchQuery([]string{userID}) + if err != nil { + return nil, err + } + groupUsers, err := s.query.SearchGroupUsers(ctx, + &query.GroupUsersSearchQuery{ + Queries: []query.SearchQuery{ + groupUserQuery, + }, + }, nil) + if err != nil { + return nil, err + } + if len(groupUsers.GroupUsers) == 0 { + return nil, nil + } + groupIDs := make([]string, 0, len(groupUsers.GroupUsers)) + for _, groupUser := range groupUsers.GroupUsers { + groupIDs = append(groupIDs, groupUser.GroupID) + } + return groupIDs, nil +} diff --git a/internal/api/grpc/group/v2/group_users.go b/internal/api/grpc/group/v2/group_users.go new file mode 100644 index 00000000000..07c771abcfa --- /dev/null +++ b/internal/api/grpc/group/v2/group_users.go @@ -0,0 +1,30 @@ +package group + +import ( + "context" + + "connectrpc.com/connect" + "google.golang.org/protobuf/types/known/timestamppb" + + group_v2 "github.com/zitadel/zitadel/pkg/grpc/group/v2" +) + +func (s *Server) AddUsersToGroup(ctx context.Context, c *connect.Request[group_v2.AddUsersToGroupRequest]) (*connect.Response[group_v2.AddUsersToGroupResponse], error) { + addUsersResp, err := s.command.AddUsersToGroup(ctx, c.Msg.GetId(), c.Msg.GetUserIds()) + if err != nil { + return nil, err + } + return connect.NewResponse(&group_v2.AddUsersToGroupResponse{ + ChangeDate: timestamppb.New(addUsersResp.EventDate), + }), nil +} + +func (s *Server) RemoveUsersFromGroup(ctx context.Context, c *connect.Request[group_v2.RemoveUsersFromGroupRequest]) (*connect.Response[group_v2.RemoveUsersFromGroupResponse], error) { + details, err := s.command.RemoveUsersFromGroup(ctx, c.Msg.GetId(), c.Msg.GetUserIds()) + if err != nil { + return nil, err + } + return connect.NewResponse(&group_v2.RemoveUsersFromGroupResponse{ + ChangeDate: timestamppb.New(details.EventDate), + }), nil +} diff --git a/internal/api/grpc/group/v2/integration_test/group_test.go b/internal/api/grpc/group/v2/integration_test/group_test.go index 5196a6b240c..03123f5016d 100644 --- a/internal/api/grpc/group/v2/integration_test/group_test.go +++ b/internal/api/grpc/group/v2/integration_test/group_test.go @@ -5,6 +5,7 @@ package group_test import ( "context" "testing" + "time" "github.com/golang/protobuf/ptypes/timestamp" "github.com/muhlemmer/gu" @@ -32,7 +33,6 @@ func TestServer_CreateGroup(t *testing.T) { wantResp bool wantGroupID string wantErrCode codes.Code - wantErrMsg string }{ { name: "unauthenticated, error", @@ -42,7 +42,6 @@ func TestServer_CreateGroup(t *testing.T) { OrganizationId: orgResp.GetOrganizationId(), }, wantErrCode: codes.Unauthenticated, - wantErrMsg: "auth header missing", }, { name: "invalid name, error", @@ -52,7 +51,6 @@ func TestServer_CreateGroup(t *testing.T) { OrganizationId: "org1", }, wantErrCode: codes.InvalidArgument, - wantErrMsg: "Errors.Group.InvalidName (GROUP-m177lN)", }, { name: "missing organization id, error", @@ -61,7 +59,6 @@ func TestServer_CreateGroup(t *testing.T) { Name: integration.GroupName(), }, wantErrCode: codes.InvalidArgument, - wantErrMsg: "invalid CreateGroupRequest.OrganizationId: value length must be between 1 and 200 runes, inclusive", }, { name: "missing permission, error", @@ -71,7 +68,6 @@ func TestServer_CreateGroup(t *testing.T) { OrganizationId: orgResp.GetOrganizationId(), }, wantErrCode: codes.NotFound, - wantErrMsg: "membership not found (AUTHZ-cdgFk)", }, { name: "organization not found, error", @@ -81,7 +77,6 @@ func TestServer_CreateGroup(t *testing.T) { OrganizationId: "org1", }, wantErrCode: codes.FailedPrecondition, - wantErrMsg: "Organisation not found (CMDGRP-j1mH8l)", }, { name: "instance owner, already existing group (unique name constraint), error", @@ -91,7 +86,6 @@ func TestServer_CreateGroup(t *testing.T) { OrganizationId: orgResp.GetOrganizationId(), }, wantErrCode: codes.AlreadyExists, - wantErrMsg: "Errors.Group.AlreadyExists (V3-DKcYh)", }, { name: "instance owner, already existing group ID, error", @@ -102,7 +96,6 @@ func TestServer_CreateGroup(t *testing.T) { OrganizationId: orgResp.GetOrganizationId(), }, wantErrCode: codes.AlreadyExists, - wantErrMsg: "Errors.Group.AlreadyExists (CMDGRP-shRut3)", }, { name: "organization owner, missing permission, error", @@ -112,7 +105,6 @@ func TestServer_CreateGroup(t *testing.T) { OrganizationId: orgResp.GetOrganizationId(), }, wantErrCode: codes.NotFound, - wantErrMsg: "membership not found (AUTHZ-cdgFk)", }, { name: "organization owner, with permission, ok", @@ -146,13 +138,14 @@ func TestServer_CreateGroup(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + beforeCreationDate := time.Now().UTC() got, err := instance.Client.GroupV2.CreateGroup(tt.ctx, tt.req) + afterCreationDate := time.Now().UTC() if tt.wantErrCode != codes.OK { require.Error(t, err) require.Empty(t, got.GetId()) require.Empty(t, got.GetCreationDate()) assert.Equal(t, tt.wantErrCode, status.Code(err)) - assert.Equal(t, tt.wantErrMsg, status.Convert(err).Message()) return } require.NoError(t, err) @@ -160,7 +153,7 @@ func TestServer_CreateGroup(t *testing.T) { assert.Equal(t, tt.wantGroupID, got.Id, "want: %v, got: %v", tt.wantGroupID, got) } require.NotEmpty(t, got.GetId()) - require.NotEmpty(t, got.GetCreationDate()) + assert.WithinRange(t, got.GetCreationDate().AsTime(), beforeCreationDate, afterCreationDate) }) } } @@ -183,7 +176,6 @@ func TestServer_UpdateGroup(t *testing.T) { req *group_v2.UpdateGroupRequest wantChangeDate bool wantErrCode codes.Code - wantErrMsg string }{ { name: "unauthenticated, error", @@ -192,7 +184,6 @@ func TestServer_UpdateGroup(t *testing.T) { Name: gu.Ptr(integration.GroupName()), }, wantErrCode: codes.Unauthenticated, - wantErrMsg: "auth header missing", }, { name: "invalid name, error", @@ -202,7 +193,6 @@ func TestServer_UpdateGroup(t *testing.T) { Name: gu.Ptr(" "), }, wantErrCode: codes.InvalidArgument, - wantErrMsg: "Errors.Group.InvalidName (GROUP-dUNd3r)", }, { name: "missing permission, error", @@ -212,7 +202,6 @@ func TestServer_UpdateGroup(t *testing.T) { Name: gu.Ptr("updated group name"), }, wantErrCode: codes.NotFound, - wantErrMsg: "membership not found (AUTHZ-cdgFk)", }, { name: "organization owner, missing permission, error", @@ -222,7 +211,6 @@ func TestServer_UpdateGroup(t *testing.T) { Name: gu.Ptr("updated group name"), }, wantErrCode: codes.NotFound, - wantErrMsg: "membership not found (AUTHZ-cdgFk)", }, { name: "organization owner, with permission, ok", @@ -241,7 +229,6 @@ func TestServer_UpdateGroup(t *testing.T) { Name: gu.Ptr("updated group name 2"), }, wantErrCode: codes.NotFound, - wantErrMsg: "Errors.Group.NotFound (CMDGRP-b33zly)", }, { name: "instance owner, no change, ok", @@ -250,7 +237,7 @@ func TestServer_UpdateGroup(t *testing.T) { Id: existingGroup.GetId(), Name: gu.Ptr(groupName), }, - wantChangeDate: true, + wantChangeDate: false, }, { name: "instance owner, change name, ok", @@ -283,16 +270,19 @@ func TestServer_UpdateGroup(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + beforeUpdateDate := time.Now().UTC() got, err := instance.Client.GroupV2.UpdateGroup(tt.ctx, tt.req) + afterUpdateDate := time.Now().UTC() if tt.wantErrCode != codes.OK { require.Error(t, err) require.Empty(t, got.GetChangeDate()) assert.Equal(t, tt.wantErrCode, status.Code(err)) - assert.Equal(t, tt.wantErrMsg, status.Convert(err).Message()) return } require.NoError(t, err) - require.NotEmpty(t, got.GetChangeDate()) + if tt.wantChangeDate { + assert.WithinRange(t, got.GetChangeDate().AsTime(), beforeUpdateDate, afterUpdateDate) + } }) } } @@ -316,12 +306,12 @@ func TestServer_DeleteGroup(t *testing.T) { deleteResp := instance.DeleteGroup(iamOwnerCtx, t, deleteGroup.GetId()) tests := []struct { - name string - ctx context.Context - req *group_v2.DeleteGroupRequest - wantErrCode codes.Code - wantErrMsg string - deletionTime *timestamp.Timestamp + name string + ctx context.Context + req *group_v2.DeleteGroupRequest + wantErrCode codes.Code + wantDeletionDate bool + deletionTime *timestamp.Timestamp }{ { name: "unauthenticated, error", @@ -330,7 +320,6 @@ func TestServer_DeleteGroup(t *testing.T) { Id: "12345", }, wantErrCode: codes.Unauthenticated, - wantErrMsg: "auth header missing", }, { name: "missing id, error", @@ -339,7 +328,6 @@ func TestServer_DeleteGroup(t *testing.T) { Id: "", }, wantErrCode: codes.InvalidArgument, - wantErrMsg: "invalid DeleteGroupRequest.Id: value length must be between 1 and 200 runes, inclusive", }, { name: "missing permission, error", @@ -348,7 +336,6 @@ func TestServer_DeleteGroup(t *testing.T) { Id: existingGroup.GetId(), }, wantErrCode: codes.NotFound, - wantErrMsg: "membership not found (AUTHZ-cdgFk)", }, { name: "organization owner, missing permission, error", @@ -357,7 +344,6 @@ func TestServer_DeleteGroup(t *testing.T) { Id: existingGroup.GetId(), }, wantErrCode: codes.NotFound, - wantErrMsg: "membership not found (AUTHZ-cdgFk)", }, { name: "organization owner, with permission, ok", @@ -365,6 +351,7 @@ func TestServer_DeleteGroup(t *testing.T) { req: &group_v2.DeleteGroupRequest{ Id: groupDefOrg.GetId(), }, + wantDeletionDate: true, }, { name: "group not found, ok", @@ -379,6 +366,7 @@ func TestServer_DeleteGroup(t *testing.T) { req: &group_v2.DeleteGroupRequest{ Id: existingGroup.GetId(), }, + wantDeletionDate: true, }, { name: "delete already deleted group, ok", @@ -391,12 +379,13 @@ func TestServer_DeleteGroup(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + beforeDeletionDate := time.Now().UTC() got, err := instance.Client.GroupV2.DeleteGroup(tt.ctx, tt.req) + afterDeletionDate := time.Now().UTC() if tt.wantErrCode != codes.OK { require.Error(t, err) require.Empty(t, got.GetDeletionDate()) assert.Equal(t, tt.wantErrCode, status.Code(err)) - assert.Equal(t, tt.wantErrMsg, status.Convert(err).Message()) return } require.NoError(t, err) @@ -404,6 +393,9 @@ func TestServer_DeleteGroup(t *testing.T) { if tt.deletionTime != nil { assert.Equal(t, tt.deletionTime, got.GetDeletionDate()) } + if tt.wantDeletionDate { + assert.WithinRange(t, got.GetDeletionDate().AsTime(), beforeDeletionDate, afterDeletionDate) + } }) } } diff --git a/internal/api/grpc/group/v2/integration_test/group_users_test.go b/internal/api/grpc/group/v2/integration_test/group_users_test.go new file mode 100644 index 00000000000..816203f1bd4 --- /dev/null +++ b/internal/api/grpc/group/v2/integration_test/group_users_test.go @@ -0,0 +1,323 @@ +//go:build integration + +package group_test + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/zitadel/zitadel/internal/integration" + group_v2 "github.com/zitadel/zitadel/pkg/grpc/group/v2" +) + +func TestServer_AddUsersToGroup(t *testing.T) { + iamOwnerCtx := instance.WithAuthorizationToken(CTX, integration.UserTypeIAMOwner) + // group and user in the default org + defOrgGroup := instance.CreateGroup(iamOwnerCtx, t, instance.DefaultOrg.GetId(), integration.GroupName()) + defOrgUser := instance.CreateHumanUserVerified(iamOwnerCtx, instance.DefaultOrg.GetId(), integration.Email(), integration.Phone()) + + // org1 + org1 := instance.CreateOrganization(iamOwnerCtx, integration.OrganizationName(), integration.Email()) + // group in org1 + group := instance.CreateGroup(iamOwnerCtx, t, org1.GetOrganizationId(), integration.GroupName()) + // user1 in org1 + user1 := instance.CreateHumanUserVerified(iamOwnerCtx, org1.OrganizationId, integration.Email(), integration.Phone()) + // user2 in org1 + user2 := instance.CreateHumanUserVerified(iamOwnerCtx, org1.OrganizationId, integration.Email(), integration.Phone()) + // user3 in org1 + user3 := instance.CreateHumanUserVerified(iamOwnerCtx, org1.OrganizationId, integration.Email(), integration.Phone()) + // user4 in org1 + user4 := instance.CreateHumanUserVerified(iamOwnerCtx, org1.OrganizationId, integration.Email(), integration.Phone()) + + tests := []struct { + name string + ctx context.Context + req *group_v2.AddUsersToGroupRequest + wantChangeDate bool + wantErrCode codes.Code + }{ + { + name: "unauthenticated, error", + ctx: context.Background(), + req: &group_v2.AddUsersToGroupRequest{ + Id: group.GetId(), + UserIds: []string{user1.GetUserId(), user2.GetUserId()}, + }, + wantErrCode: codes.Unauthenticated, + }, + { + name: "missing id, error", + ctx: iamOwnerCtx, + req: &group_v2.AddUsersToGroupRequest{ + UserIds: []string{user1.GetUserId(), user2.GetUserId()}, + }, + wantErrCode: codes.InvalidArgument, + }, + { + name: "missing user ids, error", + ctx: iamOwnerCtx, + req: &group_v2.AddUsersToGroupRequest{ + Id: group.GetId(), + }, + wantErrCode: codes.InvalidArgument, + }, + { + name: "group does not exist, error", + ctx: iamOwnerCtx, + req: &group_v2.AddUsersToGroupRequest{ + Id: "randomGroup", + UserIds: []string{user1.GetUserId(), user2.GetUserId()}, + }, + wantErrCode: codes.FailedPrecondition, + }, + { + name: "missing permission, error", + ctx: instance.WithAuthorizationToken(CTX, integration.UserTypeNoPermission), + req: &group_v2.AddUsersToGroupRequest{ + Id: group.GetId(), + UserIds: []string{user1.GetUserId(), user2.GetUserId()}, + }, + wantErrCode: codes.NotFound, + }, + { + name: "organization owner, missing permission, error", + ctx: instance.WithAuthorizationToken(CTX, integration.UserTypeOrgOwner), + req: &group_v2.AddUsersToGroupRequest{ + Id: group.GetId(), + UserIds: []string{user1.GetUserId(), user2.GetUserId()}, + }, + wantErrCode: codes.NotFound, + }, + { + name: "organization owner, with permission, ok", + ctx: instance.WithAuthorizationToken(CTX, integration.UserTypeOrgOwner), + req: &group_v2.AddUsersToGroupRequest{ + Id: defOrgGroup.GetId(), + UserIds: []string{defOrgUser.GetUserId()}, + }, + }, + { + name: "some users not found, ok", + ctx: iamOwnerCtx, + req: &group_v2.AddUsersToGroupRequest{ + Id: group.GetId(), + UserIds: []string{user1.GetUserId(), user2.GetUserId(), "randomUser"}, + }, + wantErrCode: codes.FailedPrecondition, + }, + { + name: "user in a different org not added, ok", + ctx: iamOwnerCtx, + req: &group_v2.AddUsersToGroupRequest{ + Id: group.GetId(), + UserIds: []string{user1.GetUserId(), user2.GetUserId(), defOrgUser.GetUserId()}, + }, + wantErrCode: codes.FailedPrecondition, + }, + { + name: "add all users to group, ok", + ctx: iamOwnerCtx, + req: &group_v2.AddUsersToGroupRequest{ + Id: group.GetId(), + UserIds: []string{user1.GetUserId(), user2.GetUserId()}, + }, + wantChangeDate: true, + }, + { + name: "some users already in the group, ok", + ctx: iamOwnerCtx, + req: &group_v2.AddUsersToGroupRequest{ + Id: group.GetId(), + UserIds: []string{user1.GetUserId(), user2.GetUserId(), user3.GetUserId()}, + }, + wantChangeDate: true, + }, + { + name: "add all users to group (with duplicate user IDs), ok", + ctx: iamOwnerCtx, + req: &group_v2.AddUsersToGroupRequest{ + Id: group.GetId(), + UserIds: []string{user1.GetUserId(), user4.GetUserId(), user4.GetUserId()}, + }, + wantChangeDate: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + beforeDate := time.Now().UTC() + got, err := instance.Client.GroupV2.AddUsersToGroup(tt.ctx, tt.req) + afterDate := time.Now().UTC() + if tt.wantErrCode != codes.OK { + require.Error(t, err) + require.Empty(t, got.GetChangeDate()) + assert.Equal(t, tt.wantErrCode, status.Code(err)) + return + } + require.NoError(t, err) + if tt.wantChangeDate { + assert.WithinRange(t, got.GetChangeDate().AsTime(), beforeDate, afterDate) + } + }) + } +} + +func TestServer_RemoveUsersFromGroup(t *testing.T) { + iamOwnerCtx := instance.WithAuthorizationToken(CTX, integration.UserTypeIAMOwner) + // group and user in the default org + defOrgGroup := instance.CreateGroup(iamOwnerCtx, t, instance.DefaultOrg.GetId(), integration.GroupName()) + defOrgUser := instance.CreateHumanUserVerified(iamOwnerCtx, instance.DefaultOrg.GetId(), integration.Email(), integration.Phone()) + + // add user to the group in def org + _, err := instance.Client.GroupV2.AddUsersToGroup(iamOwnerCtx, &group_v2.AddUsersToGroupRequest{ + Id: defOrgGroup.GetId(), + UserIds: []string{defOrgUser.GetUserId()}, + }) + require.NoError(t, err) + + // org1 + org1 := instance.CreateOrganization(iamOwnerCtx, integration.OrganizationName(), integration.Email()) + // group in org1 + group := instance.CreateGroup(iamOwnerCtx, t, org1.GetOrganizationId(), integration.GroupName()) + // user1 in org1 + user1 := instance.CreateHumanUserVerified(iamOwnerCtx, org1.OrganizationId, integration.Email(), integration.Phone()) + // user2 in org1 + user2 := instance.CreateHumanUserVerified(iamOwnerCtx, org1.OrganizationId, integration.Email(), integration.Phone()) + // user3 in org1 + user3 := instance.CreateHumanUserVerified(iamOwnerCtx, org1.OrganizationId, integration.Email(), integration.Phone()) + + // add user1, user2, user3 to the group + _, err = instance.Client.GroupV2.AddUsersToGroup(iamOwnerCtx, &group_v2.AddUsersToGroupRequest{ + Id: group.GetId(), + UserIds: []string{user1.GetUserId(), user2.GetUserId(), user3.GetUserId()}, + }) + require.NoError(t, err) + + tests := []struct { + name string + ctx context.Context + req *group_v2.RemoveUsersFromGroupRequest + wantChangeDate bool + wantErrCode codes.Code + }{ + { + name: "unauthenticated, error", + ctx: context.Background(), + req: &group_v2.RemoveUsersFromGroupRequest{ + Id: group.GetId(), + UserIds: []string{user1.GetUserId(), user2.GetUserId()}, + }, + wantErrCode: codes.Unauthenticated, + }, + { + name: "missing id, error", + ctx: iamOwnerCtx, + req: &group_v2.RemoveUsersFromGroupRequest{ + UserIds: []string{user1.GetUserId(), user2.GetUserId()}, + }, + wantErrCode: codes.InvalidArgument, + }, + { + name: "missing user ids, error", + ctx: iamOwnerCtx, + req: &group_v2.RemoveUsersFromGroupRequest{ + Id: group.GetId(), + }, + wantErrCode: codes.InvalidArgument, + }, + { + name: "group does not exist, error", + ctx: iamOwnerCtx, + req: &group_v2.RemoveUsersFromGroupRequest{ + Id: "randomGroup", + UserIds: []string{user1.GetUserId(), user2.GetUserId()}, + }, + wantErrCode: codes.FailedPrecondition, + }, + { + name: "missing permission, error", + ctx: instance.WithAuthorizationToken(CTX, integration.UserTypeNoPermission), + req: &group_v2.RemoveUsersFromGroupRequest{ + Id: group.GetId(), + UserIds: []string{user1.GetUserId(), user2.GetUserId()}, + }, + wantErrCode: codes.NotFound, + }, + { + name: "organization owner, missing permission, error", + ctx: instance.WithAuthorizationToken(CTX, integration.UserTypeOrgOwner), + req: &group_v2.RemoveUsersFromGroupRequest{ + Id: group.GetId(), + UserIds: []string{user1.GetUserId(), user2.GetUserId()}, + }, + wantErrCode: codes.NotFound, + }, + { + name: "organization owner, with permission, ok", + ctx: instance.WithAuthorizationToken(CTX, integration.UserTypeOrgOwner), + req: &group_v2.RemoveUsersFromGroupRequest{ + Id: defOrgGroup.GetId(), + UserIds: []string{defOrgUser.GetUserId()}, + }, + wantChangeDate: true, + }, + { + name: "users not in the group, ok", + ctx: iamOwnerCtx, + req: &group_v2.RemoveUsersFromGroupRequest{ + Id: group.GetId(), + UserIds: []string{"user3", "user4"}, + }, + wantChangeDate: false, + }, + { + name: "some users not in the group, ok", + ctx: iamOwnerCtx, + req: &group_v2.RemoveUsersFromGroupRequest{ + Id: group.GetId(), + UserIds: []string{user1.GetUserId(), defOrgUser.GetUserId(), "user3"}, + }, + wantChangeDate: true, + }, + { + name: "users removed, ok", + ctx: iamOwnerCtx, + req: &group_v2.RemoveUsersFromGroupRequest{ + Id: group.GetId(), + UserIds: []string{user2.GetUserId()}, + }, + wantChangeDate: true, + }, + { + name: "users removed (with duplicate user IDs), ok", + ctx: iamOwnerCtx, + req: &group_v2.RemoveUsersFromGroupRequest{ + Id: group.GetId(), + UserIds: []string{user3.GetUserId(), user3.GetUserId()}, + }, + wantChangeDate: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + beforeDate := time.Now().UTC() + got, err := instance.Client.GroupV2.RemoveUsersFromGroup(tt.ctx, tt.req) + afterDate := time.Now().UTC() + if tt.wantErrCode != codes.OK { + require.Error(t, err) + require.Empty(t, got.GetChangeDate()) + assert.Equal(t, tt.wantErrCode, status.Code(err)) + return + } + require.NoError(t, err) + if tt.wantChangeDate { + assert.WithinRange(t, got.GetChangeDate().AsTime(), beforeDate, afterDate) + } + }) + } +} diff --git a/internal/api/grpc/group/v2/integration_test/query_test.go b/internal/api/grpc/group/v2/integration_test/query_test.go index a31b8c016e2..5deca672af9 100644 --- a/internal/api/grpc/group/v2/integration_test/query_test.go +++ b/internal/api/grpc/group/v2/integration_test/query_test.go @@ -13,8 +13,10 @@ import ( "google.golang.org/grpc/status" "github.com/zitadel/zitadel/internal/integration" + authorization "github.com/zitadel/zitadel/pkg/grpc/authorization/v2beta" "github.com/zitadel/zitadel/pkg/grpc/filter/v2" group_v2 "github.com/zitadel/zitadel/pkg/grpc/group/v2" + "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) func TestServer_GetGroup(t *testing.T) { @@ -863,3 +865,648 @@ func TestServer_ListGroups_WithPermissionV2(t *testing.T) { }) } } + +func TestServer_ListGroupUsers(t *testing.T) { + iamOwnerCtx := instance.WithAuthorizationToken(CTX, integration.UserTypeIAMOwner) + type args struct { + ctx context.Context + req *group_v2.ListGroupUsersRequest + dep func(*group_v2.ListGroupUsersRequest, *group_v2.ListGroupUsersResponse) + } + tests := []struct { + name string + args args + want *group_v2.ListGroupUsersResponse + wantErrCode codes.Code + wantErrMsg string + }{ + { + name: "list groups, unauthenticated", + args: args{ + ctx: CTX, + req: &group_v2.ListGroupUsersRequest{}, + }, + wantErrCode: codes.Unauthenticated, + wantErrMsg: "auth header missing", + }, + { + name: "no permission, empty list, TotalResult count returned", + args: args{ + ctx: instance.WithAuthorizationToken(CTX, integration.UserTypeNoPermission), + dep: func(req *group_v2.ListGroupUsersRequest, resp *group_v2.ListGroupUsersResponse) { + orgResp := instance.CreateOrganization(iamOwnerCtx, integration.OrganizationName(), integration.Email()) + groupID, _, _ := addUsersToGroup(iamOwnerCtx, t, instance, orgResp.GetOrganizationId(), 2) + + req.Filters[0].Filter = &group_v2.GroupUsersSearchFilter_GroupIds{ + GroupIds: &filter.InIDsFilter{ + Ids: []string{groupID}, + }, + } + }, + req: &group_v2.ListGroupUsersRequest{ + Filters: []*group_v2.GroupUsersSearchFilter{{}}, + }, + }, + want: &group_v2.ListGroupUsersResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 2, + AppliedLimit: 100, + }, + GroupUsers: []*group_v2.GroupUser{}, + }, + }, + { + name: "org owner, missing permission, empty list, TotalResult count returned", + args: args{ + ctx: instance.WithAuthorizationToken(CTX, integration.UserTypeOrgOwner), + dep: func(req *group_v2.ListGroupUsersRequest, resp *group_v2.ListGroupUsersResponse) { + orgResp := instance.CreateOrganization(iamOwnerCtx, integration.OrganizationName(), integration.Email()) + groupID, _, _ := addUsersToGroup(iamOwnerCtx, t, instance, orgResp.GetOrganizationId(), 2) + + req.Filters[0].Filter = &group_v2.GroupUsersSearchFilter_GroupIds{ + GroupIds: &filter.InIDsFilter{ + Ids: []string{groupID}, + }, + } + }, + req: &group_v2.ListGroupUsersRequest{ + Filters: []*group_v2.GroupUsersSearchFilter{{}}, + }, + }, + want: &group_v2.ListGroupUsersResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 2, + AppliedLimit: 100, + }, + GroupUsers: []*group_v2.GroupUser{}, + }, + }, + { + name: "org owner, with permission, ok", + args: args{ + ctx: instance.WithAuthorizationToken(CTX, integration.UserTypeOrgOwner), + dep: func(req *group_v2.ListGroupUsersRequest, resp *group_v2.ListGroupUsersResponse) { + groupID, users, addUsersResp := addUsersToGroup(iamOwnerCtx, t, instance, instance.DefaultOrg.GetId(), 2) + + user0, err := instance.Client.UserV2.GetUserByID(iamOwnerCtx, &user.GetUserByIDRequest{UserId: users[0]}) + require.NoError(t, err) + + user1, err := instance.Client.UserV2.GetUserByID(iamOwnerCtx, &user.GetUserByIDRequest{UserId: users[1]}) + require.NoError(t, err) + + req.Filters[0].Filter = &group_v2.GroupUsersSearchFilter_GroupIds{ + GroupIds: &filter.InIDsFilter{ + Ids: []string{groupID}, + }, + } + resp.GroupUsers[0] = &group_v2.GroupUser{ + GroupId: groupID, + OrganizationId: instance.DefaultOrg.GetId(), + User: &authorization.User{ + Id: user0.User.GetUserId(), + OrganizationId: user0.User.Details.ResourceOwner, + PreferredLoginName: user0.User.GetPreferredLoginName(), + DisplayName: "Mickey Mouse", + }, + CreationDate: addUsersResp.GetChangeDate(), + } + resp.GroupUsers[1] = &group_v2.GroupUser{ + GroupId: groupID, + OrganizationId: instance.DefaultOrg.GetId(), + User: &authorization.User{ + Id: user1.User.GetUserId(), + OrganizationId: instance.DefaultOrg.GetId(), + PreferredLoginName: user1.User.GetPreferredLoginName(), + DisplayName: "Mickey Mouse", + }, + CreationDate: addUsersResp.GetChangeDate(), + } + }, + req: &group_v2.ListGroupUsersRequest{ + Filters: []*group_v2.GroupUsersSearchFilter{{}}, + }, + }, + want: &group_v2.ListGroupUsersResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 2, + AppliedLimit: 100, + }, + GroupUsers: []*group_v2.GroupUser{ + {}, {}, + }, + }, + }, + { + name: "instance owner, no matching results, ok", + args: args{ + ctx: iamOwnerCtx, + req: &group_v2.ListGroupUsersRequest{ + Filters: []*group_v2.GroupUsersSearchFilter{ + { + Filter: &group_v2.GroupUsersSearchFilter_GroupIds{ + GroupIds: &filter.InIDsFilter{ + Ids: []string{"random-group"}, + }, + }, + }, + }, + }, + }, + want: &group_v2.ListGroupUsersResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 0, + AppliedLimit: 100, + }, + }, + }, + { + name: "list by multiple group IDs, ok", + args: args{ + ctx: iamOwnerCtx, + dep: func(req *group_v2.ListGroupUsersRequest, resp *group_v2.ListGroupUsersResponse) { + orgResp := instance.CreateOrganization(iamOwnerCtx, integration.OrganizationName(), integration.Email()) + group0, group0Users, addUsersResp0 := addUsersToGroup(iamOwnerCtx, t, instance, orgResp.GetOrganizationId(), 2) + group0User0, err := instance.Client.UserV2.GetUserByID(iamOwnerCtx, &user.GetUserByIDRequest{UserId: group0Users[0]}) + require.NoError(t, err) + group0User1, err := instance.Client.UserV2.GetUserByID(iamOwnerCtx, &user.GetUserByIDRequest{UserId: group0Users[1]}) + require.NoError(t, err) + + group1, group1Users, addUsersResp1 := addUsersToGroup(iamOwnerCtx, t, instance, orgResp.GetOrganizationId(), 2) + group1User0, err := instance.Client.UserV2.GetUserByID(iamOwnerCtx, &user.GetUserByIDRequest{UserId: group1Users[0]}) + require.NoError(t, err) + group1User1, err := instance.Client.UserV2.GetUserByID(iamOwnerCtx, &user.GetUserByIDRequest{UserId: group1Users[1]}) + require.NoError(t, err) + + resp.GroupUsers[0] = &group_v2.GroupUser{ + GroupId: group1, + OrganizationId: orgResp.GetOrganizationId(), + User: &authorization.User{ + Id: group1User0.User.GetUserId(), + OrganizationId: group1User0.User.Details.ResourceOwner, + PreferredLoginName: group1User0.User.GetPreferredLoginName(), + DisplayName: "Mickey Mouse", + }, + CreationDate: addUsersResp1.GetChangeDate(), + } + resp.GroupUsers[1] = &group_v2.GroupUser{ + GroupId: group1, + OrganizationId: orgResp.GetOrganizationId(), + User: &authorization.User{ + Id: group1User1.User.GetUserId(), + OrganizationId: group1User1.User.Details.ResourceOwner, + PreferredLoginName: group1User1.User.GetPreferredLoginName(), + DisplayName: "Mickey Mouse", + }, + CreationDate: addUsersResp1.GetChangeDate(), + } + resp.GroupUsers[2] = &group_v2.GroupUser{ + GroupId: group0, + OrganizationId: orgResp.GetOrganizationId(), + User: &authorization.User{ + Id: group0User0.User.GetUserId(), + OrganizationId: group0User0.User.Details.ResourceOwner, + PreferredLoginName: group0User0.User.GetPreferredLoginName(), + DisplayName: "Mickey Mouse", + }, + CreationDate: addUsersResp0.GetChangeDate(), + } + resp.GroupUsers[3] = &group_v2.GroupUser{ + GroupId: group0, + OrganizationId: orgResp.GetOrganizationId(), + User: &authorization.User{ + Id: group0User1.User.GetUserId(), + OrganizationId: group0User1.User.Details.ResourceOwner, + PreferredLoginName: group0User1.User.GetPreferredLoginName(), + DisplayName: "Mickey Mouse", + }, + CreationDate: addUsersResp0.GetChangeDate(), + } + + req.Filters[0].Filter = &group_v2.GroupUsersSearchFilter_GroupIds{ + GroupIds: &filter.InIDsFilter{ + Ids: []string{group0, group1}, + }, + } + }, + req: &group_v2.ListGroupUsersRequest{ + Filters: []*group_v2.GroupUsersSearchFilter{{}}, + }, + }, + want: &group_v2.ListGroupUsersResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 4, + AppliedLimit: 100, + }, + GroupUsers: []*group_v2.GroupUser{ + {}, {}, {}, {}, + }, + }, + }, + { + name: "list by multiple user IDs in user groups from different organizations, ok", + args: args{ + ctx: iamOwnerCtx, + dep: func(req *group_v2.ListGroupUsersRequest, resp *group_v2.ListGroupUsersResponse) { + org0 := instance.CreateOrganization(iamOwnerCtx, integration.OrganizationName(), integration.Email()) + group0, group0Users, addUsersResp0 := addUsersToGroup(iamOwnerCtx, t, instance, org0.GetOrganizationId(), 2) + group0User0, err := instance.Client.UserV2.GetUserByID(iamOwnerCtx, &user.GetUserByIDRequest{UserId: group0Users[0]}) + require.NoError(t, err) + + org1 := instance.CreateOrganization(iamOwnerCtx, integration.OrganizationName(), integration.Email()) + group1, group1Users, addUsersResp1 := addUsersToGroup(iamOwnerCtx, t, instance, org1.GetOrganizationId(), 2) + group1User1, err := instance.Client.UserV2.GetUserByID(iamOwnerCtx, &user.GetUserByIDRequest{UserId: group1Users[1]}) + require.NoError(t, err) + + resp.GroupUsers[0] = &group_v2.GroupUser{ + GroupId: group1, + OrganizationId: org1.GetOrganizationId(), + User: &authorization.User{ + Id: group1User1.User.GetUserId(), + OrganizationId: group1User1.User.Details.ResourceOwner, + PreferredLoginName: group1User1.User.GetPreferredLoginName(), + DisplayName: "Mickey Mouse", + }, + CreationDate: addUsersResp1.GetChangeDate(), + } + resp.GroupUsers[1] = &group_v2.GroupUser{ + GroupId: group0, + OrganizationId: org0.GetOrganizationId(), + User: &authorization.User{ + Id: group0User0.User.GetUserId(), + OrganizationId: group0User0.User.Details.ResourceOwner, + PreferredLoginName: group0User0.User.GetPreferredLoginName(), + DisplayName: "Mickey Mouse", + }, + CreationDate: addUsersResp0.GetChangeDate(), + } + req.Filters[0].Filter = &group_v2.GroupUsersSearchFilter_UserIds{ + UserIds: &filter.InIDsFilter{ + Ids: []string{group0User0.User.GetUserId(), group1User1.User.GetUserId()}, + }, + } + }, + req: &group_v2.ListGroupUsersRequest{ + Filters: []*group_v2.GroupUsersSearchFilter{{}}, + }, + }, + want: &group_v2.ListGroupUsersResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 2, + AppliedLimit: 100, + }, + GroupUsers: []*group_v2.GroupUser{ + {}, {}, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.args.dep != nil { + tt.args.dep(tt.args.req, tt.want) + } + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(iamOwnerCtx, time.Minute) + require.EventuallyWithT(t, func(ttt *assert.CollectT) { + got, err := instance.Client.GroupV2.ListGroupUsers(tt.args.ctx, tt.args.req) + if tt.wantErrCode != codes.OK { + require.Error(ttt, err) + assert.Equal(ttt, tt.wantErrCode, status.Code(err)) + assert.Equal(ttt, tt.wantErrMsg, status.Convert(err).Message()) + return + } + require.NoError(ttt, err) + if assert.Len(ttt, got.GroupUsers, len(tt.want.GroupUsers)) { + for i := range got.GroupUsers { + assert.EqualExportedValues(ttt, tt.want.GroupUsers[i], got.GroupUsers[i], "want: %v, got: %v", tt.want.GroupUsers[i], got.GroupUsers[i]) + } + } + assert.Equal(ttt, tt.want.Pagination.AppliedLimit, got.Pagination.AppliedLimit) + assert.Equal(ttt, tt.want.Pagination.TotalResult, got.Pagination.TotalResult) + }, retryDuration, tick, "timeout waiting for expected result") + }) + } +} + +func TestServer_ListGroupUsers_WithPermissionV2(t *testing.T) { + ensureFeaturePermissionV2Enabled(t, instancePermissionV2) + iamOwnerCtx := instancePermissionV2.WithAuthorizationToken(CTX, integration.UserTypeIAMOwner) + type args struct { + ctx context.Context + req *group_v2.ListGroupUsersRequest + dep func(*group_v2.ListGroupUsersRequest, *group_v2.ListGroupUsersResponse) + } + tests := []struct { + name string + args args + want *group_v2.ListGroupUsersResponse + wantErrCode codes.Code + wantErrMsg string + }{ + { + name: "list groups, unauthenticated", + args: args{ + ctx: CTX, + req: &group_v2.ListGroupUsersRequest{}, + }, + wantErrCode: codes.Unauthenticated, + wantErrMsg: "auth header missing", + }, + { + name: "no permission, empty list, TotalResult count set to 0", + args: args{ + ctx: instancePermissionV2.WithAuthorizationToken(CTX, integration.UserTypeNoPermission), + dep: func(req *group_v2.ListGroupUsersRequest, resp *group_v2.ListGroupUsersResponse) { + orgResp := instancePermissionV2.CreateOrganization(iamOwnerCtx, integration.OrganizationName(), integration.Email()) + groupID, _, _ := addUsersToGroup(iamOwnerCtx, t, instancePermissionV2, orgResp.GetOrganizationId(), 2) + + req.Filters[0].Filter = &group_v2.GroupUsersSearchFilter_GroupIds{ + GroupIds: &filter.InIDsFilter{ + Ids: []string{groupID}, + }, + } + }, + req: &group_v2.ListGroupUsersRequest{ + Filters: []*group_v2.GroupUsersSearchFilter{{}}, + }, + }, + want: &group_v2.ListGroupUsersResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 0, + AppliedLimit: 100, + }, + GroupUsers: []*group_v2.GroupUser{}, + }, + }, + { + name: "org owner, missing permission, empty list, TotalResult count set to 0", + args: args{ + ctx: instancePermissionV2.WithAuthorizationToken(CTX, integration.UserTypeOrgOwner), + dep: func(req *group_v2.ListGroupUsersRequest, resp *group_v2.ListGroupUsersResponse) { + orgResp := instancePermissionV2.CreateOrganization(iamOwnerCtx, integration.OrganizationName(), integration.Email()) + groupID, _, _ := addUsersToGroup(iamOwnerCtx, t, instancePermissionV2, orgResp.GetOrganizationId(), 2) + + req.Filters[0].Filter = &group_v2.GroupUsersSearchFilter_GroupIds{ + GroupIds: &filter.InIDsFilter{ + Ids: []string{groupID}, + }, + } + }, + req: &group_v2.ListGroupUsersRequest{ + Filters: []*group_v2.GroupUsersSearchFilter{{}}, + }, + }, + want: &group_v2.ListGroupUsersResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 0, + AppliedLimit: 100, + }, + GroupUsers: []*group_v2.GroupUser{}, + }, + }, + { + name: "org owner, with permission, ok", + args: args{ + ctx: instancePermissionV2.WithAuthorizationToken(CTX, integration.UserTypeOrgOwner), + dep: func(req *group_v2.ListGroupUsersRequest, resp *group_v2.ListGroupUsersResponse) { + groupID, users, addUsersResp := addUsersToGroup(iamOwnerCtx, t, instancePermissionV2, instancePermissionV2.DefaultOrg.GetId(), 2) + + user0, err := instancePermissionV2.Client.UserV2.GetUserByID(iamOwnerCtx, &user.GetUserByIDRequest{UserId: users[0]}) + require.NoError(t, err) + + user1, err := instancePermissionV2.Client.UserV2.GetUserByID(iamOwnerCtx, &user.GetUserByIDRequest{UserId: users[1]}) + require.NoError(t, err) + + req.Filters[0].Filter = &group_v2.GroupUsersSearchFilter_GroupIds{ + GroupIds: &filter.InIDsFilter{ + Ids: []string{groupID}, + }, + } + resp.GroupUsers[0] = &group_v2.GroupUser{ + GroupId: groupID, + OrganizationId: instancePermissionV2.DefaultOrg.GetId(), + User: &authorization.User{ + Id: user0.User.GetUserId(), + OrganizationId: user0.User.Details.ResourceOwner, + PreferredLoginName: user0.User.GetPreferredLoginName(), + DisplayName: "Mickey Mouse", + }, + CreationDate: addUsersResp.GetChangeDate(), + } + resp.GroupUsers[1] = &group_v2.GroupUser{ + GroupId: groupID, + OrganizationId: instancePermissionV2.DefaultOrg.GetId(), + User: &authorization.User{ + Id: user1.User.GetUserId(), + OrganizationId: instancePermissionV2.DefaultOrg.GetId(), + PreferredLoginName: user1.User.GetPreferredLoginName(), + DisplayName: "Mickey Mouse", + }, + CreationDate: addUsersResp.GetChangeDate(), + } + }, + req: &group_v2.ListGroupUsersRequest{ + Filters: []*group_v2.GroupUsersSearchFilter{{}}, + }, + }, + want: &group_v2.ListGroupUsersResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 2, + AppliedLimit: 100, + }, + GroupUsers: []*group_v2.GroupUser{ + {}, {}, + }, + }, + }, + { + name: "instance owner, no matching results, ok", + args: args{ + ctx: iamOwnerCtx, + req: &group_v2.ListGroupUsersRequest{ + Filters: []*group_v2.GroupUsersSearchFilter{ + { + Filter: &group_v2.GroupUsersSearchFilter_GroupIds{ + GroupIds: &filter.InIDsFilter{ + Ids: []string{"random-group"}, + }, + }, + }, + }, + }, + }, + want: &group_v2.ListGroupUsersResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 0, + AppliedLimit: 100, + }, + }, + }, + { + name: "list by multiple group IDs, ok", + args: args{ + ctx: iamOwnerCtx, + dep: func(req *group_v2.ListGroupUsersRequest, resp *group_v2.ListGroupUsersResponse) { + orgResp := instancePermissionV2.CreateOrganization(iamOwnerCtx, integration.OrganizationName(), integration.Email()) + group0, group0Users, addUsersResp0 := addUsersToGroup(iamOwnerCtx, t, instancePermissionV2, orgResp.GetOrganizationId(), 2) + group0User0, err := instancePermissionV2.Client.UserV2.GetUserByID(iamOwnerCtx, &user.GetUserByIDRequest{UserId: group0Users[0]}) + require.NoError(t, err) + group0User1, err := instancePermissionV2.Client.UserV2.GetUserByID(iamOwnerCtx, &user.GetUserByIDRequest{UserId: group0Users[1]}) + require.NoError(t, err) + + group1, group1Users, addUsersResp1 := addUsersToGroup(iamOwnerCtx, t, instancePermissionV2, orgResp.GetOrganizationId(), 2) + group1User0, err := instancePermissionV2.Client.UserV2.GetUserByID(iamOwnerCtx, &user.GetUserByIDRequest{UserId: group1Users[0]}) + require.NoError(t, err) + group1User1, err := instancePermissionV2.Client.UserV2.GetUserByID(iamOwnerCtx, &user.GetUserByIDRequest{UserId: group1Users[1]}) + require.NoError(t, err) + + resp.GroupUsers[0] = &group_v2.GroupUser{ + GroupId: group1, + OrganizationId: orgResp.GetOrganizationId(), + User: &authorization.User{ + Id: group1User0.User.GetUserId(), + OrganizationId: group1User0.User.Details.ResourceOwner, + PreferredLoginName: group1User0.User.GetPreferredLoginName(), + DisplayName: "Mickey Mouse", + }, + CreationDate: addUsersResp1.GetChangeDate(), + } + resp.GroupUsers[1] = &group_v2.GroupUser{ + GroupId: group1, + OrganizationId: orgResp.GetOrganizationId(), + User: &authorization.User{ + Id: group1User1.User.GetUserId(), + OrganizationId: group1User1.User.Details.ResourceOwner, + PreferredLoginName: group1User1.User.GetPreferredLoginName(), + DisplayName: "Mickey Mouse", + }, + CreationDate: addUsersResp1.GetChangeDate(), + } + resp.GroupUsers[2] = &group_v2.GroupUser{ + GroupId: group0, + OrganizationId: orgResp.GetOrganizationId(), + User: &authorization.User{ + Id: group0User0.User.GetUserId(), + OrganizationId: group0User0.User.Details.ResourceOwner, + PreferredLoginName: group0User0.User.GetPreferredLoginName(), + DisplayName: "Mickey Mouse", + }, + CreationDate: addUsersResp0.GetChangeDate(), + } + resp.GroupUsers[3] = &group_v2.GroupUser{ + GroupId: group0, + OrganizationId: orgResp.GetOrganizationId(), + User: &authorization.User{ + Id: group0User1.User.GetUserId(), + OrganizationId: group0User1.User.Details.ResourceOwner, + PreferredLoginName: group0User1.User.GetPreferredLoginName(), + DisplayName: "Mickey Mouse", + }, + CreationDate: addUsersResp0.GetChangeDate(), + } + + req.Filters[0].Filter = &group_v2.GroupUsersSearchFilter_GroupIds{ + GroupIds: &filter.InIDsFilter{ + Ids: []string{group0, group1}, + }, + } + }, + req: &group_v2.ListGroupUsersRequest{ + Filters: []*group_v2.GroupUsersSearchFilter{{}}, + }, + }, + want: &group_v2.ListGroupUsersResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 4, + AppliedLimit: 100, + }, + GroupUsers: []*group_v2.GroupUser{ + {}, {}, {}, {}, + }, + }, + }, + { + name: "list by multiple user IDs in user groups from different organizations with permission in one org, ok", + args: args{ + ctx: instancePermissionV2.WithAuthorizationToken(CTX, integration.UserTypeOrgOwner), + dep: func(req *group_v2.ListGroupUsersRequest, resp *group_v2.ListGroupUsersResponse) { + group0, group0Users, addUsersResp0 := addUsersToGroup(iamOwnerCtx, t, instancePermissionV2, instancePermissionV2.DefaultOrg.GetId(), 2) + group0User0, err := instancePermissionV2.Client.UserV2.GetUserByID(iamOwnerCtx, &user.GetUserByIDRequest{UserId: group0Users[0]}) + require.NoError(t, err) + + org1 := instancePermissionV2.CreateOrganization(iamOwnerCtx, integration.OrganizationName(), integration.Email()) + _, group1Users, _ := addUsersToGroup(iamOwnerCtx, t, instancePermissionV2, org1.GetOrganizationId(), 2) + group1User1, err := instancePermissionV2.Client.UserV2.GetUserByID(iamOwnerCtx, &user.GetUserByIDRequest{UserId: group1Users[1]}) + require.NoError(t, err) + + resp.GroupUsers[0] = &group_v2.GroupUser{ + GroupId: group0, + OrganizationId: instancePermissionV2.DefaultOrg.GetId(), + User: &authorization.User{ + Id: group0User0.User.GetUserId(), + OrganizationId: group0User0.User.Details.ResourceOwner, + PreferredLoginName: group0User0.User.GetPreferredLoginName(), + DisplayName: "Mickey Mouse", + }, + CreationDate: addUsersResp0.GetChangeDate(), + } + req.Filters[0].Filter = &group_v2.GroupUsersSearchFilter_UserIds{ + UserIds: &filter.InIDsFilter{ + Ids: []string{group0User0.User.GetUserId(), group1User1.User.GetUserId()}, + }, + } + }, + req: &group_v2.ListGroupUsersRequest{ + Filters: []*group_v2.GroupUsersSearchFilter{{}}, + }, + }, + want: &group_v2.ListGroupUsersResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + GroupUsers: []*group_v2.GroupUser{ + {}, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.args.dep != nil { + tt.args.dep(tt.args.req, tt.want) + } + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(iamOwnerCtx, time.Minute) + require.EventuallyWithT(t, func(ttt *assert.CollectT) { + got, err := instancePermissionV2.Client.GroupV2.ListGroupUsers(tt.args.ctx, tt.args.req) + if tt.wantErrCode != codes.OK { + require.Error(ttt, err) + assert.Equal(ttt, tt.wantErrCode, status.Code(err)) + assert.Equal(ttt, tt.wantErrMsg, status.Convert(err).Message()) + return + } + require.NoError(ttt, err) + if assert.Len(ttt, got.GroupUsers, len(tt.want.GroupUsers)) { + for i := range got.GroupUsers { + assert.EqualExportedValues(ttt, tt.want.GroupUsers[i], got.GroupUsers[i], "want: %v, got: %v", tt.want.GroupUsers[i], got.GroupUsers[i]) + } + } + assert.Equal(ttt, tt.want.Pagination.AppliedLimit, got.Pagination.AppliedLimit) + assert.Equal(ttt, tt.want.Pagination.TotalResult, got.Pagination.TotalResult) + }, retryDuration, tick, "timeout waiting for expected result") + }) + } +} + +func addUsersToGroup(ctx context.Context, t *testing.T, instance *integration.Instance, orgID string, numUsers int) (string, []string, *group_v2.AddUsersToGroupResponse) { + groupName := integration.GroupName() + group1 := instance.CreateGroup(ctx, t, orgID, groupName) + users := make([]string, 0, numUsers) + for i := 0; i < numUsers; i++ { + u := instance.CreateHumanUserVerified(ctx, orgID, integration.Email(), integration.Phone()) + users = append(users, u.GetUserId()) + } + addUsersResp := instance.AddUsersToGroup(ctx, t, group1.GetId(), users) + return group1.GetId(), users, addUsersResp +} diff --git a/internal/api/grpc/group/v2/query.go b/internal/api/grpc/group/v2/query.go index b5046451ffd..3919e3d30e7 100644 --- a/internal/api/grpc/group/v2/query.go +++ b/internal/api/grpc/group/v2/query.go @@ -10,6 +10,7 @@ import ( "github.com/zitadel/zitadel/internal/config/systemdefaults" "github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/internal/zerrors" + authorization_v2beta "github.com/zitadel/zitadel/pkg/grpc/authorization/v2beta" group_v2 "github.com/zitadel/zitadel/pkg/grpc/group/v2" ) @@ -40,6 +41,22 @@ func (s *Server) ListGroups(ctx context.Context, req *connect.Request[group_v2.L }), nil } +// ListGroupUsers returns a list of users from user groupUsers that match the search criteria +func (s *Server) ListGroupUsers(ctx context.Context, req *connect.Request[group_v2.ListGroupUsersRequest]) (*connect.Response[group_v2.ListGroupUsersResponse], error) { + queries, err := listGroupUsersRequestToModel(req.Msg, s.systemDefaults) + if err != nil { + return nil, err + } + resp, err := s.query.SearchGroupUsers(ctx, queries, s.checkPermission) + if err != nil { + return nil, err + } + return connect.NewResponse(&group_v2.ListGroupUsersResponse{ + GroupUsers: groupUsersToPb(resp.GroupUsers), + Pagination: filter.QueryToPaginationPb(queries.SearchRequest, resp.SearchResponse), + }), nil +} + func listGroupsRequestToModel(req *group_v2.ListGroupsRequest, systemDefaults systemdefaults.SystemDefaults) (*query.GroupSearchQuery, error) { offset, limit, asc, err := filter.PaginationPbToQuery(systemDefaults, req.GetPagination()) if err != nil { @@ -120,3 +137,85 @@ func groupToPb(g *query.Group) *group_v2.Group { ChangeDate: timestamppb.New(g.ChangeDate), } } + +func listGroupUsersRequestToModel(req *group_v2.ListGroupUsersRequest, systemDefaults systemdefaults.SystemDefaults) (*query.GroupUsersSearchQuery, error) { + offset, limit, asc, err := filter.PaginationPbToQuery(systemDefaults, req.GetPagination()) + if err != nil { + return nil, err + } + queries, err := groupUsersSearchFiltersToQuery(req.GetFilters()) + if err != nil { + return nil, err + } + return &query.GroupUsersSearchQuery{ + SearchRequest: query.SearchRequest{ + Offset: offset, + Limit: limit, + Asc: asc, + SortingColumn: groupUsersFieldNameToSortingColumn(req.SortingColumn), + }, + Queries: queries, + }, nil +} + +func groupUsersSearchFiltersToQuery(filters []*group_v2.GroupUsersSearchFilter) (_ []query.SearchQuery, err error) { + q := make([]query.SearchQuery, len(filters)) + for i, f := range filters { + q[i], err = groupUsersFilterToQuery(f) + if err != nil { + return nil, err + } + } + return q, nil +} + +func groupUsersFilterToQuery(f *group_v2.GroupUsersSearchFilter) (query.SearchQuery, error) { + switch q := f.Filter.(type) { + case *group_v2.GroupUsersSearchFilter_UserIds: + return query.NewGroupUsersUserIDsSearchQuery(q.UserIds.GetIds()) + case *group_v2.GroupUsersSearchFilter_GroupIds: + return query.NewGroupUsersGroupIDsSearchQuery(q.GroupIds.GetIds()) + default: + return nil, zerrors.ThrowInvalidArgument(nil, "GRPC-osMHhx", "List.Query.Invalid") + } +} + +func groupUsersFieldNameToSortingColumn(field *group_v2.GroupUserFieldName) query.Column { + if field == nil { + return query.GroupUsersColumnCreationDate + } + switch *field { + case group_v2.GroupUserFieldName_GROUP_USER_FIELD_NAME_CREATION_DATE, + group_v2.GroupUserFieldName_GROUP_USER_FIELD_NAME_UNSPECIFIED: + return query.GroupUsersColumnCreationDate + case group_v2.GroupUserFieldName_GROUP_USER_FIELD_NAME_USER_ID: + return query.GroupUsersColumnUserID + case group_v2.GroupUserFieldName_GROUP_USER_FIELD_NAME_GROUP_ID: + return query.GroupUsersColumnGroupID + default: + return query.GroupUsersColumnCreationDate + } +} + +func groupUsersToPb(groupUsers []*query.GroupUser) []*group_v2.GroupUser { + pbGroupUsers := make([]*group_v2.GroupUser, len(groupUsers)) + for i, gu := range groupUsers { + pbGroupUsers[i] = groupUserToPb(gu) + } + return pbGroupUsers +} + +func groupUserToPb(gu *query.GroupUser) *group_v2.GroupUser { + return &group_v2.GroupUser{ + GroupId: gu.GroupID, + OrganizationId: gu.ResourceOwner, + User: &authorization_v2beta.User{ + Id: gu.UserID, + PreferredLoginName: gu.PreferredLoginName, + DisplayName: gu.DisplayName, + AvatarUrl: gu.AvatarUrl, + OrganizationId: gu.ResourceOwner, + }, + CreationDate: timestamppb.New(gu.CreationDate), + } +} diff --git a/internal/api/grpc/group/v2/query_test.go b/internal/api/grpc/group/v2/query_test.go index c00e0c083f9..63586635e2e 100644 --- a/internal/api/grpc/group/v2/query_test.go +++ b/internal/api/grpc/group/v2/query_test.go @@ -2,7 +2,6 @@ package group import ( "errors" - "fmt" "testing" "time" @@ -15,12 +14,13 @@ import ( "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/internal/zerrors" + authorization "github.com/zitadel/zitadel/pkg/grpc/authorization/v2beta" "github.com/zitadel/zitadel/pkg/grpc/filter/v2" group_v2 "github.com/zitadel/zitadel/pkg/grpc/group/v2" ) func Test_ListGroupsRequestToModel(t *testing.T) { - + t.Parallel() groupIDsSearchQuery, err := query.NewGroupIDsSearchQuery([]string{"group1", "group2"}) require.NoError(t, err) @@ -76,16 +76,13 @@ func Test_ListGroupsRequestToModel(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() sysDefaults := systemdefaults.SystemDefaults{MaxQueryLimit: tt.maxQueryLimit} got, err := listGroupsRequestToModel(tt.req, sysDefaults) if tt.wantErr != nil { assert.Equal(t, tt.wantErr, err) return } - for _, q := range got.Queries { - fmt.Printf("%+v", q) - } - require.NoError(t, err) assert.Equal(t, tt.wantResp, got) }) @@ -93,6 +90,7 @@ func Test_ListGroupsRequestToModel(t *testing.T) { } func Test_GroupSearchFiltersToQuery(t *testing.T) { + t.Parallel() groupIDsSearchQuery, err := query.NewGroupIDsSearchQuery([]string{"group1", "group2"}) require.NoError(t, err) groupNameSearchQuery, err := query.NewGroupNameSearchQuery("mygroup", query.TextStartsWith) @@ -146,6 +144,7 @@ func Test_GroupSearchFiltersToQuery(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() got, err := groupSearchFiltersToQuery(tt.filters) if tt.wantErr != nil { assert.Equal(t, tt.wantErr, err) @@ -158,6 +157,7 @@ func Test_GroupSearchFiltersToQuery(t *testing.T) { } func Test_GroupFieldNameToSortingColumn(t *testing.T) { + t.Parallel() tests := []struct { name string field *group_v2.FieldName @@ -196,6 +196,7 @@ func Test_GroupFieldNameToSortingColumn(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() got := groupFieldNameToSortingColumn(tt.field) assert.Equal(t, tt.want, got) }) @@ -203,6 +204,7 @@ func Test_GroupFieldNameToSortingColumn(t *testing.T) { } func Test_GroupsToPb(t *testing.T) { + t.Parallel() timeNow := time.Now().UTC() tests := []struct { name string @@ -262,8 +264,299 @@ func Test_GroupsToPb(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() got := groupsToPb(tt.groups) assert.Equal(t, tt.want, got) }) } } + +func Test_ListGroupUsersRequestToModel(t *testing.T) { + t.Parallel() + groupIDsSearchQuery, err := query.NewGroupUsersGroupIDsSearchQuery([]string{"group1", "group2"}) + require.NoError(t, err) + + userIDsSearchQuery, err := query.NewGroupUsersUserIDsSearchQuery([]string{"user1", "user2"}) + require.NoError(t, err) + + tests := []struct { + name string + maxQueryLimit uint64 + req *group_v2.ListGroupUsersRequest + wantResp *query.GroupUsersSearchQuery + wantErr error + }{ + { + name: "max query limit exceeded", + maxQueryLimit: 1, + req: &group_v2.ListGroupUsersRequest{ + Pagination: &filter.PaginationRequest{ + Limit: 5, + }, + Filters: []*group_v2.GroupUsersSearchFilter{ + { + Filter: &group_v2.GroupUsersSearchFilter_GroupIds{ + GroupIds: &filter.InIDsFilter{ + Ids: []string{"group1", "group2"}, + }, + }, + }, + }, + }, + wantErr: zerrors.ThrowInvalidArgumentf(errors.New("given: 5, allowed: 1"), "QUERY-4M0fs", "Errors.Query.LimitExceeded"), + }, + { + name: "valid request, list of group IDs, ok", + req: &group_v2.ListGroupUsersRequest{ + Filters: []*group_v2.GroupUsersSearchFilter{ + { + Filter: &group_v2.GroupUsersSearchFilter_GroupIds{ + GroupIds: &filter.InIDsFilter{ + Ids: []string{"group1", "group2"}, + }, + }, + }, + }, + }, + wantResp: &query.GroupUsersSearchQuery{ + SearchRequest: query.SearchRequest{ + Offset: 0, + Limit: 0, + SortingColumn: query.GroupUsersColumnCreationDate, + }, + Queries: []query.SearchQuery{groupIDsSearchQuery}, + }, + }, + { + name: "valid request, list of user IDs, ok", + req: &group_v2.ListGroupUsersRequest{ + Filters: []*group_v2.GroupUsersSearchFilter{ + { + Filter: &group_v2.GroupUsersSearchFilter_UserIds{ + UserIds: &filter.InIDsFilter{ + Ids: []string{"user1", "user2"}, + }, + }, + }, + }, + }, + wantResp: &query.GroupUsersSearchQuery{ + SearchRequest: query.SearchRequest{ + Offset: 0, + Limit: 0, + SortingColumn: query.GroupUsersColumnCreationDate, + }, + Queries: []query.SearchQuery{userIDsSearchQuery}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + sysDefaults := systemdefaults.SystemDefaults{MaxQueryLimit: tt.maxQueryLimit} + got, err := listGroupUsersRequestToModel(tt.req, sysDefaults) + if tt.wantErr != nil { + assert.Equal(t, tt.wantErr, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantResp, got) + }) + } +} + +func Test_GroupUsersSearchFiltersToQuery(t *testing.T) { + t.Parallel() + groupIDsSearchQuery, err := query.NewGroupUsersGroupIDsSearchQuery([]string{"group1", "group2"}) + require.NoError(t, err) + + userIDsSearchQuery, err := query.NewGroupUsersUserIDsSearchQuery([]string{"user1", "user2"}) + require.NoError(t, err) + + tests := []struct { + name string + filters []*group_v2.GroupUsersSearchFilter + want []query.SearchQuery + wantErr error + }{ + { + name: "empty", + filters: []*group_v2.GroupUsersSearchFilter{}, + want: []query.SearchQuery{}, + }, + { + name: "all filters", + filters: []*group_v2.GroupUsersSearchFilter{ + { + Filter: &group_v2.GroupUsersSearchFilter_GroupIds{ + GroupIds: &filter.InIDsFilter{ + Ids: []string{"group1", "group2"}, + }, + }, + }, + { + Filter: &group_v2.GroupUsersSearchFilter_UserIds{ + UserIds: &filter.InIDsFilter{ + Ids: []string{"user1", "user2"}, + }, + }, + }, + }, + want: []query.SearchQuery{ + groupIDsSearchQuery, + userIDsSearchQuery, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, err := groupUsersSearchFiltersToQuery(tt.filters) + if tt.wantErr != nil { + assert.Equal(t, tt.wantErr, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func Test_GroupUsersFieldNameToSortingColumn(t *testing.T) { + t.Parallel() + tests := []struct { + name string + field *group_v2.GroupUserFieldName + want query.Column + }{ + { + name: "nil", + field: nil, + want: query.GroupUsersColumnCreationDate, + }, + { + name: "creation date", + field: gu.Ptr(group_v2.GroupUserFieldName_GROUP_USER_FIELD_NAME_CREATION_DATE), + want: query.GroupUsersColumnCreationDate, + }, + { + name: "unspecified", + field: gu.Ptr(group_v2.GroupUserFieldName_GROUP_USER_FIELD_NAME_CREATION_DATE), + want: query.GroupUsersColumnCreationDate, + }, + { + name: "group id", + field: gu.Ptr(group_v2.GroupUserFieldName_GROUP_USER_FIELD_NAME_GROUP_ID), + want: query.GroupUsersColumnGroupID, + }, + { + name: "user id", + field: gu.Ptr(group_v2.GroupUserFieldName_GROUP_USER_FIELD_NAME_USER_ID), + want: query.GroupUsersColumnUserID, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := groupUsersFieldNameToSortingColumn(tt.field) + assert.Equal(t, tt.want, got) + }) + } +} + +func Test_GroupUsersToPb(t *testing.T) { + t.Parallel() + timeNow := time.Now().UTC() + tests := []struct { + name string + groupUsers []*query.GroupUser + want []*group_v2.GroupUser + }{ + { + name: "empty", + groupUsers: []*query.GroupUser{}, + want: []*group_v2.GroupUser{}, + }, + { + name: "with groupUsers, ok", + groupUsers: []*query.GroupUser{ + { + GroupID: "group1", + ResourceOwner: "org1", + CreationDate: timeNow, + Sequence: 1, + UserID: "user1", + PreferredLoginName: "user1", + DisplayName: "user1", + AvatarUrl: "example.com/user1.png", + }, + { + GroupID: "group1", + ResourceOwner: "org1", + CreationDate: timeNow, + Sequence: 1, + UserID: "user2", + PreferredLoginName: "user2", + DisplayName: "user2", + AvatarUrl: "example.com/user2.png", + }, + { + GroupID: "group2", + ResourceOwner: "org1", + CreationDate: timeNow, + Sequence: 1, + UserID: "user1", + PreferredLoginName: "user1", + DisplayName: "user1", + AvatarUrl: "example.com/user1.png", + }, + }, + want: []*group_v2.GroupUser{ + { + GroupId: "group1", + OrganizationId: "org1", + User: &authorization.User{ + Id: "user1", + DisplayName: "user1", + PreferredLoginName: "user1", + AvatarUrl: "example.com/user1.png", + OrganizationId: "org1", + }, + CreationDate: timestamppb.New(timeNow), + }, + { + GroupId: "group1", + OrganizationId: "org1", + User: &authorization.User{ + Id: "user2", + DisplayName: "user2", + PreferredLoginName: "user2", + AvatarUrl: "example.com/user2.png", + OrganizationId: "org1", + }, + CreationDate: timestamppb.New(timeNow), + }, + { + GroupId: "group2", + OrganizationId: "org1", + User: &authorization.User{ + Id: "user1", + DisplayName: "user1", + PreferredLoginName: "user1", + AvatarUrl: "example.com/user1.png", + OrganizationId: "org1", + }, + CreationDate: timestamppb.New(timeNow), + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := groupUsersToPb(tt.groupUsers) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/internal/api/grpc/management/user.go b/internal/api/grpc/management/user.go index 440213ae455..79df85bff34 100644 --- a/internal/api/grpc/management/user.go +++ b/internal/api/grpc/management/user.go @@ -349,11 +349,11 @@ func (s *Server) UnlockUser(ctx context.Context, req *mgmt_pb.UnlockUserRequest) } func (s *Server) RemoveUser(ctx context.Context, req *mgmt_pb.RemoveUserRequest) (*mgmt_pb.RemoveUserResponse, error) { - memberships, grants, err := s.removeUserDependencies(ctx, req.Id) + memberships, grants, groupIDs, err := s.removeUserDependencies(ctx, req.Id) if err != nil { return nil, err } - objectDetails, err := s.command.RemoveUser(ctx, req.Id, authz.GetCtxData(ctx).OrgID, memberships, grants...) + objectDetails, err := s.command.RemoveUser(ctx, req.Id, authz.GetCtxData(ctx).OrgID, memberships, grants, groupIDs) if err != nil { return nil, err } @@ -362,28 +362,32 @@ func (s *Server) RemoveUser(ctx context.Context, req *mgmt_pb.RemoveUserRequest) }, nil } -func (s *Server) removeUserDependencies(ctx context.Context, userID string) ([]*command.CascadingMembership, []string, error) { +func (s *Server) removeUserDependencies(ctx context.Context, userID string) ([]*command.CascadingMembership, []string, []string, error) { userGrantUserQuery, err := query.NewUserGrantUserIDSearchQuery(userID) if err != nil { - return nil, nil, err + return nil, nil, nil, err } grants, err := s.query.UserGrants(ctx, &query.UserGrantsQueries{ Queries: []query.SearchQuery{userGrantUserQuery}, }, true, nil) if err != nil { - return nil, nil, err + return nil, nil, nil, err } membershipsUserQuery, err := query.NewMembershipUserIDQuery(userID) if err != nil { - return nil, nil, err + return nil, nil, nil, err } memberships, err := s.query.Memberships(ctx, &query.MembershipSearchQuery{ Queries: []query.SearchQuery{membershipsUserQuery}, }, false) if err != nil { - return nil, nil, err + return nil, nil, nil, err } - return cascadingMemberships(memberships.Memberships), userGrantsToIDs(grants.UserGrants), nil + groupIDs, err := s.getGroupsByUserID(ctx, userID) + if err != nil { + return nil, nil, nil, err + } + return cascadingMemberships(memberships.Memberships), userGrantsToIDs(grants.UserGrants), groupIDs, nil } func (s *Server) UpdateUserName(ctx context.Context, req *mgmt_pb.UpdateUserNameRequest) (*mgmt_pb.UpdateUserNameResponse, error) { @@ -977,3 +981,27 @@ func userGrantsToIDs(userGrants []*query.UserGrant) []string { } return converted } + +func (s *Server) getGroupsByUserID(ctx context.Context, userID string) ([]string, error) { + groupUserQuery, err := query.NewGroupUsersUserIDsSearchQuery([]string{userID}) + if err != nil { + return nil, err + } + groupUsers, err := s.query.SearchGroupUsers(ctx, + &query.GroupUsersSearchQuery{ + Queries: []query.SearchQuery{ + groupUserQuery, + }, + }, nil) + if err != nil { + return nil, err + } + if len(groupUsers.GroupUsers) == 0 { + return nil, nil + } + groupIDs := make([]string, 0, len(groupUsers.GroupUsers)) + for _, groupUser := range groupUsers.GroupUsers { + groupIDs = append(groupIDs, groupUser.GroupID) + } + return groupIDs, nil +} diff --git a/internal/api/grpc/user/v2/user.go b/internal/api/grpc/user/v2/user.go index 3eeda8da5f4..3b1260d5228 100644 --- a/internal/api/grpc/user/v2/user.go +++ b/internal/api/grpc/user/v2/user.go @@ -184,11 +184,11 @@ func ifNotNilPtr[v, p any](value *v, conv func(v) p) *p { } func (s *Server) DeleteUser(ctx context.Context, req *connect.Request[user.DeleteUserRequest]) (_ *connect.Response[user.DeleteUserResponse], err error) { - memberships, grants, err := s.removeUserDependencies(ctx, req.Msg.GetUserId()) + memberships, grants, groupIDs, err := s.removeUserDependencies(ctx, req.Msg.GetUserId()) if err != nil { return nil, err } - details, err := s.command.RemoveUserV2(ctx, req.Msg.GetUserId(), "", memberships, grants...) + details, err := s.command.RemoveUserV2(ctx, req.Msg.GetUserId(), "", memberships, grants, groupIDs) if err != nil { return nil, err } @@ -197,28 +197,32 @@ func (s *Server) DeleteUser(ctx context.Context, req *connect.Request[user.Delet }), nil } -func (s *Server) removeUserDependencies(ctx context.Context, userID string) ([]*command.CascadingMembership, []string, error) { +func (s *Server) removeUserDependencies(ctx context.Context, userID string) ([]*command.CascadingMembership, []string, []string, error) { userGrantUserQuery, err := query.NewUserGrantUserIDSearchQuery(userID) if err != nil { - return nil, nil, err + return nil, nil, nil, err } grants, err := s.query.UserGrants(ctx, &query.UserGrantsQueries{ Queries: []query.SearchQuery{userGrantUserQuery}, }, true, nil) if err != nil { - return nil, nil, err + return nil, nil, nil, err } membershipsUserQuery, err := query.NewMembershipUserIDQuery(userID) if err != nil { - return nil, nil, err + return nil, nil, nil, err } memberships, err := s.query.Memberships(ctx, &query.MembershipSearchQuery{ Queries: []query.SearchQuery{membershipsUserQuery}, }, false) if err != nil { - return nil, nil, err + return nil, nil, nil, err } - return cascadingMemberships(memberships.Memberships), userGrantsToIDs(grants.UserGrants), nil + groupIDs, err := s.getGroupsByUserID(ctx, userID) + if err != nil { + return nil, nil, nil, err + } + return cascadingMemberships(memberships.Memberships), userGrantsToIDs(grants.UserGrants), groupIDs, nil } func cascadingMemberships(memberships []*query.Membership) []*command.CascadingMembership { @@ -426,3 +430,27 @@ func (s *Server) UpdateUser(ctx context.Context, req *connect.Request[user.Updat return nil, zerrors.ThrowUnimplemented(nil, "", "user type is not implemented") } } + +func (s *Server) getGroupsByUserID(ctx context.Context, userID string) ([]string, error) { + groupUserQuery, err := query.NewGroupUsersUserIDsSearchQuery([]string{userID}) + if err != nil { + return nil, err + } + groupUsers, err := s.query.SearchGroupUsers(ctx, + &query.GroupUsersSearchQuery{ + Queries: []query.SearchQuery{ + groupUserQuery, + }, + }, nil) + if err != nil { + return nil, err + } + if len(groupUsers.GroupUsers) == 0 { + return nil, nil + } + groupIDs := make([]string, 0, len(groupUsers.GroupUsers)) + for _, groupUser := range groupUsers.GroupUsers { + groupIDs = append(groupIDs, groupUser.GroupID) + } + return groupIDs, nil +} diff --git a/internal/api/grpc/user/v2beta/user.go b/internal/api/grpc/user/v2beta/user.go index 3cde7b773e1..44c6a2f4f38 100644 --- a/internal/api/grpc/user/v2beta/user.go +++ b/internal/api/grpc/user/v2beta/user.go @@ -276,11 +276,11 @@ func (s *Server) AddIDPLink(ctx context.Context, req *connect.Request[user.AddID } func (s *Server) DeleteUser(ctx context.Context, req *connect.Request[user.DeleteUserRequest]) (_ *connect.Response[user.DeleteUserResponse], err error) { - memberships, grants, err := s.removeUserDependencies(ctx, req.Msg.GetUserId()) + memberships, grants, groupIDs, err := s.removeUserDependencies(ctx, req.Msg.GetUserId()) if err != nil { return nil, err } - details, err := s.command.RemoveUserV2(ctx, req.Msg.GetUserId(), "", memberships, grants...) + details, err := s.command.RemoveUserV2(ctx, req.Msg.GetUserId(), "", memberships, grants, groupIDs) if err != nil { return nil, err } @@ -289,28 +289,32 @@ func (s *Server) DeleteUser(ctx context.Context, req *connect.Request[user.Delet }), nil } -func (s *Server) removeUserDependencies(ctx context.Context, userID string) ([]*command.CascadingMembership, []string, error) { +func (s *Server) removeUserDependencies(ctx context.Context, userID string) ([]*command.CascadingMembership, []string, []string, error) { userGrantUserQuery, err := query.NewUserGrantUserIDSearchQuery(userID) if err != nil { - return nil, nil, err + return nil, nil, nil, err } grants, err := s.query.UserGrants(ctx, &query.UserGrantsQueries{ Queries: []query.SearchQuery{userGrantUserQuery}, }, true, nil) if err != nil { - return nil, nil, err + return nil, nil, nil, err } membershipsUserQuery, err := query.NewMembershipUserIDQuery(userID) if err != nil { - return nil, nil, err + return nil, nil, nil, err } memberships, err := s.query.Memberships(ctx, &query.MembershipSearchQuery{ Queries: []query.SearchQuery{membershipsUserQuery}, }, false) if err != nil { - return nil, nil, err + return nil, nil, nil, err } - return cascadingMemberships(memberships.Memberships), userGrantsToIDs(grants.UserGrants), nil + groupIDs, err := s.getGroupsByUserID(ctx, userID) + if err != nil { + return nil, nil, nil, err + } + return cascadingMemberships(memberships.Memberships), userGrantsToIDs(grants.UserGrants), groupIDs, nil } func cascadingMemberships(memberships []*query.Membership) []*command.CascadingMembership { @@ -645,3 +649,27 @@ func authMethodTypeToPb(methodType domain.UserAuthMethodType) user.Authenticatio return user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_UNSPECIFIED } } + +func (s *Server) getGroupsByUserID(ctx context.Context, userID string) ([]string, error) { + groupUserQuery, err := query.NewGroupUsersUserIDsSearchQuery([]string{userID}) + if err != nil { + return nil, err + } + groupUsers, err := s.query.SearchGroupUsers(ctx, + &query.GroupUsersSearchQuery{ + Queries: []query.SearchQuery{ + groupUserQuery, + }, + }, nil) + if err != nil { + return nil, err + } + if len(groupUsers.GroupUsers) == 0 { + return nil, nil + } + groupIDs := make([]string, 0, len(groupUsers.GroupUsers)) + for _, groupUser := range groupUsers.GroupUsers { + groupIDs = append(groupIDs, groupUser.GroupID) + } + return groupIDs, nil +} diff --git a/internal/api/scim/resources/user.go b/internal/api/scim/resources/user.go index dbf97e0eaeb..80349d3dcbd 100644 --- a/internal/api/scim/resources/user.go +++ b/internal/api/scim/resources/user.go @@ -200,11 +200,11 @@ func (h *UsersHandler) Update(ctx context.Context, id string, operations patch.O } func (h *UsersHandler) Delete(ctx context.Context, id string) error { - memberships, grants, err := h.queryUserDependencies(ctx, id) + memberships, grants, groupIDs, err := h.queryUserDependencies(ctx, id) if err != nil { return err } - _, err = h.command.RemoveUserV2(ctx, id, authz.GetCtxData(ctx).OrgID, memberships, grants...) + _, err = h.command.RemoveUserV2(ctx, id, authz.GetCtxData(ctx).OrgID, memberships, grants, groupIDs) return err } @@ -254,22 +254,22 @@ func (h *UsersHandler) List(ctx context.Context, request *ListRequest) (*ListRes return NewListResponse(users.SearchResponse.Count, q.SearchRequest, scimUsers), nil } -func (h *UsersHandler) queryUserDependencies(ctx context.Context, userID string) ([]*command.CascadingMembership, []string, error) { +func (h *UsersHandler) queryUserDependencies(ctx context.Context, userID string) ([]*command.CascadingMembership, []string, []string, error) { userGrantUserQuery, err := query.NewUserGrantUserIDSearchQuery(userID) if err != nil { - return nil, nil, err + return nil, nil, nil, err } grants, err := h.query.UserGrants(ctx, &query.UserGrantsQueries{ Queries: []query.SearchQuery{userGrantUserQuery}, }, true, nil) if err != nil { - return nil, nil, err + return nil, nil, nil, err } membershipsUserQuery, err := query.NewMembershipUserIDQuery(userID) if err != nil { - return nil, nil, err + return nil, nil, nil, err } memberships, err := h.query.Memberships(ctx, &query.MembershipSearchQuery{ @@ -277,7 +277,35 @@ func (h *UsersHandler) queryUserDependencies(ctx context.Context, userID string) }, false) if err != nil { - return nil, nil, err + return nil, nil, nil, err } - return cascadingMemberships(memberships.Memberships), userGrantsToIDs(grants.UserGrants), nil + groupIDs, err := h.getGroupsByUserID(ctx, userID) + if err != nil { + return nil, nil, nil, err + } + return cascadingMemberships(memberships.Memberships), userGrantsToIDs(grants.UserGrants), groupIDs, nil +} + +func (h *UsersHandler) getGroupsByUserID(ctx context.Context, userID string) ([]string, error) { + groupUserQuery, err := query.NewGroupUsersUserIDsSearchQuery([]string{userID}) + if err != nil { + return nil, err + } + groupUsers, err := h.query.SearchGroupUsers(ctx, + &query.GroupUsersSearchQuery{ + Queries: []query.SearchQuery{ + groupUserQuery, + }, + }, nil) + if err != nil { + return nil, err + } + if len(groupUsers.GroupUsers) == 0 { + return nil, nil + } + groupIDs := make([]string, 0, len(groupUsers.GroupUsers)) + for _, groupUser := range groupUsers.GroupUsers { + groupIDs = append(groupIDs, groupUser.GroupID) + } + return groupIDs, nil } diff --git a/internal/command/group.go b/internal/command/group.go index cf1593d8ded..8b06a30d528 100644 --- a/internal/command/group.go +++ b/internal/command/group.go @@ -56,7 +56,7 @@ func (c *Commands) CreateGroup(ctx context.Context, group *CreateGroup) (details } // check if a group with the same ID already exists - groupWriteModel, err := c.getGroupWriteModelByID(ctx, group.AggregateID, group.ResourceOwner) + groupWriteModel, err := c.getGroupWriteModelByID(ctx, group.AggregateID, group.ResourceOwner, nil) if err != nil { return nil, err } @@ -100,7 +100,7 @@ func (c *Commands) UpdateGroup(ctx context.Context, groupUpdate *UpdateGroup) (d return nil, err } - existingGroup, err := c.getGroupWriteModelByID(ctx, groupUpdate.AggregateID, groupUpdate.ResourceOwner) + existingGroup, err := c.getGroupWriteModelByID(ctx, groupUpdate.AggregateID, groupUpdate.ResourceOwner, nil) if err != nil { return nil, err } @@ -136,7 +136,7 @@ func (c *Commands) DeleteGroup(ctx context.Context, groupID string) (details *do ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() - existingGroup, err := c.getGroupWriteModelByID(ctx, groupID, "") + existingGroup, err := c.getGroupWriteModelByID(ctx, groupID, "", nil) if err != nil { return nil, err } @@ -160,11 +160,22 @@ func (c *Commands) DeleteGroup(ctx context.Context, groupID string) (details *do return writeModelToObjectDetails(&existingGroup.WriteModel), nil } -func (c *Commands) getGroupWriteModelByID(ctx context.Context, id, orgID string) (*GroupWriteModel, error) { - groupWriteModel := NewGroupWriteModel(id, orgID) +func (c *Commands) getGroupWriteModelByID(ctx context.Context, groupID, orgID string, userIDs []string) (*GroupWriteModel, error) { + groupWriteModel := NewGroupWriteModel(groupID, orgID, userIDs) err := c.eventstore.FilterToQueryReducer(ctx, groupWriteModel) if err != nil { return nil, err } return groupWriteModel, nil } + +func (c *Commands) checkGroupExists(ctx context.Context, groupID string, userIDs []string) (*GroupWriteModel, error) { + group, err := c.getGroupWriteModelByID(ctx, groupID, "", userIDs) + if err != nil { + return nil, err + } + if !group.State.Exists() { + return nil, zerrors.ThrowPreconditionFailed(nil, "CMDGRP-eQfeur", "Errors.Group.NotFound") + } + return group, nil +} diff --git a/internal/command/group_model.go b/internal/command/group_model.go index 916e7815009..4918d78c53d 100644 --- a/internal/command/group_model.go +++ b/internal/command/group_model.go @@ -16,29 +16,43 @@ type GroupWriteModel struct { Description string State domain.GroupState + + UserIDs []string + existingUserIDs map[string]struct{} } // NewGroupWriteModel initializes a new instance of GroupWriteModel from the given Group. -func NewGroupWriteModel(id, orgID string) *GroupWriteModel { +func NewGroupWriteModel(groupID, orgID string, userIDs []string) *GroupWriteModel { return &GroupWriteModel{ WriteModel: eventstore.WriteModel{ - AggregateID: id, + AggregateID: groupID, ResourceOwner: orgID, }, + UserIDs: userIDs, + existingUserIDs: make(map[string]struct{}), } } +func (g *GroupWriteModel) GetWriteModel() *eventstore.WriteModel { + return &g.WriteModel +} + // Query constructs a search query for retrieving group-related events based on the GroupWriteModel attributes. func (g *GroupWriteModel) Query() *eventstore.SearchQueryBuilder { + eventTypes := []eventstore.EventType{ + group.GroupAddedEventType, + group.GroupChangedEventType, + group.GroupRemovedEventType, + } + if g.UserIDs != nil { + eventTypes = append(eventTypes, group.GroupUsersAddedEventType, group.GroupUsersRemovedEventType) + } return eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). ResourceOwner(g.ResourceOwner). AddQuery(). AggregateTypes(group.AggregateType). AggregateIDs(g.AggregateID). - EventTypes( - group.GroupAddedEventType, - group.GroupChangedEventType, - group.GroupRemovedEventType).Builder() + EventTypes(eventTypes...).Builder() } func (g *GroupWriteModel) Reduce() error { @@ -58,6 +72,14 @@ func (g *GroupWriteModel) Reduce() error { } case *group.GroupRemovedEvent: g.State = domain.GroupStateRemoved + case *group.GroupUsersAddedEvent: + for _, userID := range e.UserIDs { + g.existingUserIDs[userID] = struct{}{} + } + case *group.GroupUsersRemovedEvent: + for _, userID := range e.UserIDs { + delete(g.existingUserIDs, userID) + } } } return g.WriteModel.Reduce() diff --git a/internal/command/group_users.go b/internal/command/group_users.go new file mode 100644 index 00000000000..46f663f2285 --- /dev/null +++ b/internal/command/group_users.go @@ -0,0 +1,131 @@ +package command + +import ( + "context" + "slices" + + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore" + repo "github.com/zitadel/zitadel/internal/repository/group" + "github.com/zitadel/zitadel/internal/telemetry/tracing" +) + +func (c *Commands) AddUsersToGroup(ctx context.Context, groupID string, userIDs []string) (_ *domain.ObjectDetails, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + // precondition: check whether the group exists + group, err := c.checkGroupExists(ctx, groupID, userIDs) + if err != nil { + return nil, err + } + + // check whether the requester has permissions to add users to the group + err = c.checkPermissionAddUserToGroup(ctx, group.ResourceOwner, group.AggregateID) + if err != nil { + return nil, err + } + + // add the users to the group + return c.addUsersToGroup(ctx, group) +} + +func (c *Commands) RemoveUsersFromGroup(ctx context.Context, groupID string, userIDs []string) (_ *domain.ObjectDetails, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + // precondition: check whether the group exists + group, err := c.checkGroupExists(ctx, groupID, userIDs) + if err != nil { + return nil, err + } + + // check whether the requester has permissions to remove users from the group + err = c.checkPermissionRemoveUserFromGroup(ctx, group.ResourceOwner, group.AggregateID) + if err != nil { + return nil, err + } + + userIDsToRemove := group.getUserIDsToRemove() + if len(userIDsToRemove) == 0 { + // the userIDs are not present in the group; desired state achieved + return writeModelToObjectDetails(&group.WriteModel), nil + } + + // remove users from the group + return c.pushAppendAndReduceDetails(ctx, + group, + repo.NewGroupUsersRemovedEvent( + ctx, + GroupAggregateFromWriteModel(ctx, &group.WriteModel), + userIDsToRemove, + )) +} + +func (c *Commands) addUsersToGroup(ctx context.Context, group *GroupWriteModel) (*domain.ObjectDetails, error) { + userIDsToAdd := group.getUserIDsToAdd() + if len(userIDsToAdd) == 0 { + // no new users to add + return writeModelToObjectDetails(&group.WriteModel), nil + } + + // precondition: check whether the users exist + for _, userID := range userIDsToAdd { + // check whether the user exists in the same organization as the group + _, err := c.checkUserExists(ctx, userID, group.ResourceOwner) + if err != nil { + return nil, err + } + } + + // add users to the group + return c.pushAppendAndReduceDetails(ctx, + group, + repo.NewGroupUsersAddedEvent( + ctx, + GroupAggregateFromWriteModel(ctx, &group.WriteModel), + userIDsToAdd, + ), + ) +} + +// getUserIDsToAdd returns the userIDs that are not already in the group +func (g *GroupWriteModel) getUserIDsToAdd() []string { + userIDsToAdd := make([]string, 0) + for _, userID := range g.UserIDs { + if _, ok := g.existingUserIDs[userID]; !ok && !slices.Contains(userIDsToAdd, userID) { + userIDsToAdd = append(userIDsToAdd, userID) + } + } + return userIDsToAdd +} + +// getUserIDsToRemove returns the userIDs that are in the group and should be removed +// if a userID is not in the group, the desired state has already been achieved +func (g *GroupWriteModel) getUserIDsToRemove() []string { + userIDsToRemove := make([]string, 0) + for _, userID := range g.UserIDs { + if _, ok := g.existingUserIDs[userID]; ok && !slices.Contains(userIDsToRemove, userID) { + userIDsToRemove = append(userIDsToRemove, userID) + } + } + return userIDsToRemove +} + +// removeUserFromGroups returns the events to remove a user from multiple groups. +// This is needed when a user is deleted and subsequently needs to be removed from all groups. +// Note: Ensure that the groupIDs are retrieved via SearchGroupUsers before calling this method +func (c *Commands) removeUserFromGroups(ctx context.Context, userID string, groupIDs []string, resourceOwner string) ([]eventstore.Command, error) { + events := make([]eventstore.Command, 0, len(groupIDs)) + for _, groupID := range groupIDs { + events = append( + events, + repo.NewGroupUsersRemovedEvent( + ctx, + &repo.NewAggregate(groupID, resourceOwner).Aggregate, + []string{userID}, + ), + ) + } + return events, nil +} diff --git a/internal/command/group_users_test.go b/internal/command/group_users_test.go new file mode 100644 index 00000000000..fe8484ac1e2 --- /dev/null +++ b/internal/command/group_users_test.go @@ -0,0 +1,541 @@ +package command + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/group" + "github.com/zitadel/zitadel/internal/repository/user" + "github.com/zitadel/zitadel/internal/zerrors" +) + +func TestCommands_AddUsersToGroup(t *testing.T) { + t.Parallel() + filterErr := errors.New("filter error") + pushErr := errors.New("push error") + + type fields struct { + eventstore func(t *testing.T) *eventstore.Eventstore + checkPermission domain.PermissionCheck + } + type args struct { + groupID string + userIDs []string + } + tests := []struct { + name string + fields fields + args args + want *domain.ObjectDetails + wantErr func(error) bool + }{ + { + name: "failed to get group write model, error", + fields: fields{ + eventstore: expectEventstore( + expectFilterError(filterErr), + ), + }, + args: args{ + groupID: "group1", + userIDs: []string{"user1", "user2"}, + }, + wantErr: func(err error) bool { + return errors.Is(err, filterErr) + }, + }, + { + name: "group not found, error", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + ), + }, + args: args{ + groupID: "group1", + userIDs: []string{"user1", "user2"}, + }, + wantErr: zerrors.IsPreconditionFailed, + }, + { + name: "missing permission, error", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + addNewGroupEvent("group1", "org1"), + ), + )), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args: args{ + groupID: "group1", + userIDs: []string{"user1", "user2"}, + }, + wantErr: zerrors.IsPermissionDenied, + }, + { + name: "user not found, error", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + addNewGroupEvent("group1", "org1"), + ), + ), + expectFilter(), // to get the user write model for user1 + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + groupID: "group1", + userIDs: []string{"user1", "user2"}, + }, + wantErr: zerrors.IsPreconditionFailed, + }, + { + name: "some users already exist in the group, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( // to get the group write model + eventFromEventPusher( + addNewGroupEvent("group1", "org1"), + ), + eventFromEventPusher( + addNewGroupUsersAddedEvent("group1", "org1", []string{"user1"}), + ), + ), + expectFilter( // to get the user write model to check if user1 exists + eventFromEventPusher( + addNewUserEvent("user2", "org1"), + ), + ), + expectPush( + group.NewGroupUsersAddedEvent(context.Background(), + &group.NewAggregate("group1", "org1").Aggregate, + []string{"user2"}, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + groupID: "group1", + userIDs: []string{"user1", "user2"}, + }, + want: &domain.ObjectDetails{ + ID: "group1", + ResourceOwner: "org1", + }, + }, + { + name: "all users already exist in the group, no events pushed, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( // to get the group write model + eventFromEventPusher( + addNewGroupEvent("group1", "org1"), + ), + eventFromEventPusher( + addNewGroupUsersAddedEvent("group1", "org1", []string{"user1", "user2"}), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + groupID: "group1", + userIDs: []string{"user1", "user2"}, + }, + want: &domain.ObjectDetails{ + ID: "group1", + ResourceOwner: "org1", + }, + }, + { + name: "failed to push events, error", + fields: fields{ + eventstore: expectEventstore( + expectFilter( // to get the group write model + eventFromEventPusher( + addNewGroupEvent("group1", "org1"), + ), + ), + expectFilter( // to get the user write model for user1 + eventFromEventPusher( + addNewUserEvent("user1", "org1"), + ), + ), + expectPushFailed( + pushErr, + group.NewGroupUsersAddedEvent(context.Background(), + &group.NewAggregate("group1", "org1").Aggregate, + []string{"user1"}, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + groupID: "group1", + userIDs: []string{"user1"}, + }, + wantErr: func(err error) bool { + return errors.Is(err, pushErr) + }, + }, + { + name: "all users added, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( // to get the group write model + eventFromEventPusher( + addNewGroupEvent("group1", "org1"), + ), + ), + expectFilter( // to get the user write model for user1 + eventFromEventPusher( + addNewUserEvent("user1", "org1"), + ), + ), + expectFilter( // to get the user write model for user2 + eventFromEventPusher( + addNewUserEvent("user2", "org1"), + ), + ), + expectPush( + group.NewGroupUsersAddedEvent(context.Background(), + &group.NewAggregate("group1", "org1").Aggregate, + []string{"user1", "user2"}, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + groupID: "group1", + userIDs: []string{"user1", "user2"}, + }, + want: &domain.ObjectDetails{ + ID: "group1", + ResourceOwner: "org1", + }, + }, + { + name: "all users added (with duplicate users), ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( // to get the group write model + eventFromEventPusher( + addNewGroupEvent("group1", "org1"), + ), + ), + expectFilter( // to get the user write model for user1 + eventFromEventPusher( + addNewUserEvent("user1", "org1"), + ), + ), + expectFilter( // to get the user write model for user2 + eventFromEventPusher( + addNewUserEvent("user2", "org1"), + ), + ), + expectPush( + group.NewGroupUsersAddedEvent(context.Background(), + &group.NewAggregate("group1", "org1").Aggregate, + []string{"user1", "user2"}, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + groupID: "group1", + userIDs: []string{"user1", "user2", "user2", "user1"}, + }, + want: &domain.ObjectDetails{ + ID: "group1", + ResourceOwner: "org1", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + c := &Commands{ + eventstore: tt.fields.eventstore(t), + checkPermission: tt.fields.checkPermission, + } + got, err := c.AddUsersToGroup(context.Background(), tt.args.groupID, tt.args.userIDs) + if tt.wantErr != nil { + require.True(t, tt.wantErr(err)) + return + } + assertObjectDetails(t, tt.want, got) + }) + } + +} + +func addNewGroupEvent(groupID, orgID string) *group.GroupAddedEvent { + return group.NewGroupAddedEvent(context.Background(), + &group.NewAggregate(groupID, orgID).Aggregate, + groupID, + "group description", + ) +} + +func TestCommands_RemoveUsersFromGroup(t *testing.T) { + t.Parallel() + filterErr := errors.New("filter error") + pushErr := errors.New("push error") + + type fields struct { + eventstore func(t *testing.T) *eventstore.Eventstore + checkPermission domain.PermissionCheck + } + type args struct { + groupID string + userIDs []string + } + tests := []struct { + name string + fields fields + args args + want *domain.ObjectDetails + wantErr func(error) bool + }{ + { + name: "failed to get group write model, error", + fields: fields{ + eventstore: expectEventstore( + expectFilterError(filterErr), + ), + }, + args: args{ + groupID: "group1", + userIDs: []string{"user1", "user2"}, + }, + wantErr: func(err error) bool { + return errors.Is(err, filterErr) + }, + }, + { + name: "group not found, error", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + ), + }, + args: args{ + groupID: "group1", + userIDs: []string{"user1", "user2"}, + }, + wantErr: zerrors.IsPreconditionFailed, + }, + { + name: "missing permission, error", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + addNewGroupEvent("group1", "org1"), + ), + )), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args: args{ + groupID: "group1", + userIDs: []string{"user1", "user2"}, + }, + wantErr: zerrors.IsPermissionDenied, + }, + { + name: "remove users, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( // to get the group write model + eventFromEventPusher( + addNewGroupEvent("group1", "org1"), + ), + eventFromEventPusher( + addNewGroupUsersAddedEvent("group1", "org1", []string{"user1", "user2"}), + ), + ), + expectPush( + group.NewGroupUsersRemovedEvent(context.Background(), + &group.NewAggregate("group1", "org1").Aggregate, + []string{"user1", "user2"}, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + groupID: "group1", + userIDs: []string{"user1", "user2"}, + }, + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + ID: "group1", + }, + }, + { + name: "remove users (with duplicates), ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( // to get the group write model + eventFromEventPusher( + addNewGroupEvent("group1", "org1"), + ), + eventFromEventPusher( + addNewGroupUsersAddedEvent("group1", "org1", []string{"user1", "user2"}), + ), + ), + expectPush( + group.NewGroupUsersRemovedEvent(context.Background(), + &group.NewAggregate("group1", "org1").Aggregate, + []string{"user1", "user2"}, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + groupID: "group1", + userIDs: []string{"user1", "user2", "user1", "user2", "user1", "user2"}, + }, + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + ID: "group1", + }, + }, + { + name: "some users not in group, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( // to get the group write model + eventFromEventPusher( + addNewGroupEvent("group1", "org1"), + ), + eventFromEventPusher( + addNewGroupUsersAddedEvent("group1", "org1", []string{"user1", "user2"}), + ), + ), + expectPush( + group.NewGroupUsersRemovedEvent(context.Background(), + &group.NewAggregate("group1", "org1").Aggregate, + []string{"user2"}, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + groupID: "group1", + userIDs: []string{"user2", "user3"}, + }, + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + ID: "group1", + }, + }, + { + name: "all users not in group, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( // to get the group write model + eventFromEventPusher( + addNewGroupEvent("group1", "org1"), + ), + eventFromEventPusher( + addNewGroupUsersAddedEvent("group1", "org1", []string{"user1", "user2"}), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + groupID: "group1", + userIDs: []string{"user3", "user4"}, + }, + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + ID: "group1", + }, + }, + { + name: "failed to push events, error", + fields: fields{ + eventstore: expectEventstore( + expectFilter( // to get the group write model + eventFromEventPusher( + addNewGroupEvent("group1", "org1"), + ), + eventFromEventPusher( + addNewGroupUsersAddedEvent("group1", "org1", []string{"user1", "user2"}), + ), + ), + expectPushFailed( + pushErr, + group.NewGroupUsersRemovedEvent(context.Background(), + &group.NewAggregate("group1", "org1").Aggregate, + []string{"user1"}, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + groupID: "group1", + userIDs: []string{"user1"}, + }, + wantErr: func(err error) bool { + return errors.Is(err, pushErr) + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + c := &Commands{ + eventstore: tt.fields.eventstore(t), + checkPermission: tt.fields.checkPermission, + } + got, err := c.RemoveUsersFromGroup(context.Background(), tt.args.groupID, tt.args.userIDs) + if tt.wantErr != nil { + require.True(t, tt.wantErr(err)) + return + } + assertObjectDetails(t, tt.want, got) + }) + } +} + +func addNewGroupUsersAddedEvent(groupID, orgID string, userIds []string) *group.GroupUsersAddedEvent { + return group.NewGroupUsersAddedEvent(context.Background(), + &group.NewAggregate(groupID, orgID).Aggregate, + userIds, + ) +} + +func addNewUserEvent(userID, orgID string) *user.HumanAddedEvent { + return user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate(userID, orgID).Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + AllowedLanguage, + domain.GenderUnspecified, + "email@test.ch", + true, + ) +} diff --git a/internal/command/permission_checks.go b/internal/command/permission_checks.go index 75ffdcf1642..42445c6b86a 100644 --- a/internal/command/permission_checks.go +++ b/internal/command/permission_checks.go @@ -172,3 +172,11 @@ func (c *Commands) checkPermissionUpdateGroup(ctx context.Context, resourceOwner func (c *Commands) checkPermissionDeleteGroup(ctx context.Context, resourceOwner, groupID string) error { return c.newPermissionCheck(ctx, domain.PermissionGroupDelete, group.AggregateType)(resourceOwner, groupID) } + +func (c *Commands) checkPermissionAddUserToGroup(ctx context.Context, resourceOwner, groupID string) error { + return c.newPermissionCheck(ctx, domain.PermissionGroupUserWrite, group.AggregateType)(resourceOwner, groupID) +} + +func (c *Commands) checkPermissionRemoveUserFromGroup(ctx context.Context, resourceOwner, groupID string) error { + return c.newPermissionCheck(ctx, domain.PermissionGroupUserDelete, group.AggregateType)(resourceOwner, groupID) +} diff --git a/internal/command/user.go b/internal/command/user.go index b803c5496ed..ada16c8ff04 100644 --- a/internal/command/user.go +++ b/internal/command/user.go @@ -177,7 +177,7 @@ func (c *Commands) UnlockUser(ctx context.Context, userID, resourceOwner string) return writeModelToObjectDetails(&existingUser.WriteModel), nil } -func (c *Commands) RemoveUser(ctx context.Context, userID, resourceOwner string, cascadingUserMemberships []*CascadingMembership, cascadingGrantIDs ...string) (*domain.ObjectDetails, error) { +func (c *Commands) RemoveUser(ctx context.Context, userID, resourceOwner string, cascadingUserMemberships []*CascadingMembership, cascadingGrantIDs, cascadingGroupIDs []string) (*domain.ObjectDetails, error) { if userID == "" { return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-2M0ds", "Errors.User.UserIDMissing") } @@ -219,6 +219,15 @@ func (c *Commands) RemoveUser(ctx context.Context, userID, resourceOwner string, events = append(events, membershipEvents...) } + // remove user from user groups + if len(cascadingGroupIDs) > 0 { + groupUserEvents, err := c.removeUserFromGroups(ctx, userID, cascadingGroupIDs, resourceOwner) + if err != nil { + return nil, err + } + events = append(events, groupUserEvents...) + } + pushedEvents, err := c.eventstore.Push(ctx, events...) if err != nil { return nil, err diff --git a/internal/command/user_test.go b/internal/command/user_test.go index c971f0939de..6abc1047a2b 100644 --- a/internal/command/user_test.go +++ b/internal/command/user_test.go @@ -11,6 +11,7 @@ import ( "github.com/zitadel/zitadel/internal/command/preparation" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/group" "github.com/zitadel/zitadel/internal/repository/instance" "github.com/zitadel/zitadel/internal/repository/org" "github.com/zitadel/zitadel/internal/repository/project" @@ -1157,6 +1158,7 @@ func TestCommandSide_RemoveUser(t *testing.T) { userID string cascadeUserMemberships []*CascadingMembership cascadeUserGrants []string + cascadeUserGroups []string } ) type res struct { @@ -1506,13 +1508,87 @@ func TestCommandSide_RemoveUser(t *testing.T) { }, }, }, + { + name: "remove user with user groups, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email@test.ch", + true, + ), + ), + eventFromEventPusher( + group.NewGroupUsersAddedEvent(context.Background(), + &group.NewAggregate("group1", "org1").Aggregate, + []string{"user1"}, + ), + ), + eventFromEventPusher( + group.NewGroupUsersAddedEvent(context.Background(), + &group.NewAggregate("group2", "org1").Aggregate, + []string{"user1"}, + ), + ), + ), + expectFilter(), + expectFilter( + eventFromEventPusher( + instance.NewDomainPolicyAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + true, + true, + true, + ), + ), + ), + expectFilterOrganizationSettings("org1", false, false), + expectPush( + user.NewUserRemovedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + nil, + true, + ), + group.NewGroupUsersRemovedEvent(context.Background(), + &group.NewAggregate("group1", "org1").Aggregate, + []string{"user1"}, + ), + group.NewGroupUsersRemovedEvent(context.Background(), + &group.NewAggregate("group2", "org1").Aggregate, + []string{"user1"}, + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + userID: "user1", + cascadeUserGroups: []string{"group1", "group2"}, + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ eventstore: tt.fields.eventstore(t), } - got, err := r.RemoveUser(tt.args.ctx, tt.args.userID, tt.args.orgID, tt.args.cascadeUserMemberships, tt.args.cascadeUserGrants...) + got, err := r.RemoveUser(tt.args.ctx, tt.args.userID, tt.args.orgID, tt.args.cascadeUserMemberships, tt.args.cascadeUserGrants, tt.args.cascadeUserGroups) if tt.res.err == nil { assert.NoError(t, err) } else if !tt.res.err(err) { diff --git a/internal/command/user_v2.go b/internal/command/user_v2.go index 1963e5e3001..50eb8e7b805 100644 --- a/internal/command/user_v2.go +++ b/internal/command/user_v2.go @@ -128,7 +128,7 @@ func (c *Commands) userStateWriteModel(ctx context.Context, userID string) (writ return writeModel, nil } -func (c *Commands) RemoveUserV2(ctx context.Context, userID, resourceOwner string, cascadingUserMemberships []*CascadingMembership, cascadingGrantIDs ...string) (*domain.ObjectDetails, error) { +func (c *Commands) RemoveUserV2(ctx context.Context, userID, resourceOwner string, cascadingUserMemberships []*CascadingMembership, cascadingGrantIDs, cascadingGroupIDs []string) (*domain.ObjectDetails, error) { if userID == "" { return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-vaipl7s13l", "Errors.User.UserIDMissing") } @@ -171,6 +171,15 @@ func (c *Commands) RemoveUserV2(ctx context.Context, userID, resourceOwner strin events = append(events, membershipEvents...) } + // remove user from user groups + if len(cascadingGroupIDs) > 0 { + groupUserEvents, err := c.removeUserFromGroups(ctx, userID, cascadingGroupIDs, resourceOwner) + if err != nil { + return nil, err + } + events = append(events, groupUserEvents...) + } + pushedEvents, err := c.eventstore.Push(ctx, events...) if err != nil { return nil, err diff --git a/internal/command/user_v2_test.go b/internal/command/user_v2_test.go index 80408e5f94b..fd69cf815bd 100644 --- a/internal/command/user_v2_test.go +++ b/internal/command/user_v2_test.go @@ -12,6 +12,7 @@ import ( "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/group" "github.com/zitadel/zitadel/internal/repository/org" "github.com/zitadel/zitadel/internal/repository/user" "github.com/zitadel/zitadel/internal/zerrors" @@ -1099,6 +1100,7 @@ func TestCommandSide_RemoveUserV2(t *testing.T) { userID string cascadingMemberships []*CascadingMembership grantIDs []string + groupIDs []string } ) type res struct { @@ -1351,6 +1353,81 @@ func TestCommandSide_RemoveUserV2(t *testing.T) { }, }, }, + { + name: "remove user with user groups, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(ctx, + userAgg, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email@test.ch", + true, + ), + ), + eventFromEventPusher( + group.NewGroupUsersAddedEvent(context.Background(), + &group.NewAggregate("group1", "org1").Aggregate, + []string{"user1"}, + ), + ), + eventFromEventPusher( + group.NewGroupUsersAddedEvent(context.Background(), + &group.NewAggregate("group2", "org1").Aggregate, + []string{"user1"}, + ), + ), + ), + expectFilter( + eventFromEventPusher( + org.NewDomainPolicyAddedEvent(context.Background(), + orgAgg, + true, + true, + true, + ), + ), + ), + expectFilterOrganizationSettings("org1", false, false), + expectPush( + user.NewUserRemovedEvent(ctx, + userAgg, + "username", + nil, + true, + ), + group.NewGroupUsersRemovedEvent(ctx, + &group.NewAggregate("group1", "").Aggregate, + []string{"user1"}, + ), + group.NewGroupUsersRemovedEvent(ctx, + &group.NewAggregate("group2", "").Aggregate, + []string{"user1"}, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + userID: "user1", + groupIDs: []string{ + "group1", + "group2", + }, + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, { name: "remove self, permission denied", fields: fields{ @@ -1389,7 +1466,7 @@ func TestCommandSide_RemoveUserV2(t *testing.T) { checkPermission: tt.fields.checkPermission, } - got, err := r.RemoveUserV2(ctx, tt.args.userID, "", tt.args.cascadingMemberships, tt.args.grantIDs...) + got, err := r.RemoveUserV2(ctx, tt.args.userID, "", tt.args.cascadingMemberships, tt.args.grantIDs, tt.args.groupIDs) if tt.res.err == nil { assert.NoError(t, err) } diff --git a/internal/domain/permission.go b/internal/domain/permission.go index df4dd396b76..092611b6d4d 100644 --- a/internal/domain/permission.go +++ b/internal/domain/permission.go @@ -78,6 +78,9 @@ const ( PermissionGroupWrite = "group.write" PermissionGroupRead = "group.read" PermissionGroupDelete = "group.delete" + PermissionGroupUserWrite = "group.user.write" + PermissionGroupUserRead = "group.user.read" + PermissionGroupUserDelete = "group.user.delete" ) // ProjectPermissionCheck is used as a check for preconditions dependent on application, project, user resourceowner and usergrants. diff --git a/internal/integration/client.go b/internal/integration/client.go index 5b42f822a74..a97aae64fc9 100644 --- a/internal/integration/client.go +++ b/internal/integration/client.go @@ -1323,3 +1323,12 @@ func (i *Instance) DeleteGroup(ctx context.Context, t *testing.T, id string) *gr require.NoError(t, err) return resp } + +func (i *Instance) AddUsersToGroup(ctx context.Context, t *testing.T, groupID string, userIDs []string) *group_v2.AddUsersToGroupResponse { + resp, err := i.Client.GroupV2.AddUsersToGroup(ctx, &group_v2.AddUsersToGroupRequest{ + Id: groupID, + UserIds: userIDs, + }) + require.NoError(t, err) + return resp +} diff --git a/internal/query/group_users.go b/internal/query/group_users.go new file mode 100644 index 00000000000..2a9ea36e424 --- /dev/null +++ b/internal/query/group_users.go @@ -0,0 +1,220 @@ +package query + +import ( + "context" + "database/sql" + "slices" + "time" + + sq "github.com/Masterminds/squirrel" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/query/projection" + "github.com/zitadel/zitadel/internal/telemetry/tracing" + "github.com/zitadel/zitadel/internal/zerrors" +) + +var ( + groupUsersTable = table{ + name: projection.GroupUsersProjectionTable, + instanceIDCol: projection.GroupUsersColumnInstanceID, + } + GroupUsersColumnGroupID = Column{ + name: projection.GroupUsersColumnGroupID, + table: groupUsersTable, + } + GroupUsersColumnUserID = Column{ + name: projection.GroupUsersColumnUserID, + table: groupUsersTable, + } + GroupUsersColumnResourceOwner = Column{ + name: projection.GroupUsersColumnResourceOwner, + table: groupUsersTable, + } + GroupUsersColumnCreationDate = Column{ + name: projection.GroupUsersColumnCreationDate, + table: groupUsersTable, + } + GroupUsersColumnInstanceID = Column{ + name: projection.GroupUsersColumnInstanceID, + table: groupUsersTable, + } + GroupUsersColumnSequence = Column{ + name: projection.GroupUsersColumnSequence, + table: groupUsersTable, + } +) + +type GroupUsers struct { + SearchResponse + GroupUsers []*GroupUser +} + +type GroupUser struct { + GroupID string + ResourceOwner string + CreationDate time.Time + Sequence uint64 + + // user fields + UserID string + PreferredLoginName string + DisplayName string + AvatarUrl string +} + +type GroupUsersSearchQuery struct { + SearchRequest + Queries []SearchQuery +} + +func (q *Queries) SearchGroupUsers(ctx context.Context, queries *GroupUsersSearchQuery, permissionCheck domain.PermissionCheck) (_ *GroupUsers, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + permissionCheckV2 := PermissionV2(ctx, permissionCheck) + + groupUsers, err := q.searchGroupUsers(ctx, queries, permissionCheckV2) + if err != nil { + return nil, err + } + if permissionCheck != nil && !permissionCheckV2 { + groupUsersCheckPermission(ctx, groupUsers, permissionCheck) + } + return groupUsers, nil +} + +func NewGroupUsersUserIDsSearchQuery(userIDs []string) (SearchQuery, error) { + list := make([]interface{}, len(userIDs)) + for i, value := range userIDs { + list[i] = value + } + return NewListQuery(GroupUsersColumnUserID, list, ListIn) +} + +func NewGroupUsersGroupIDsSearchQuery(groupIDs []string) (SearchQuery, error) { + list := make([]interface{}, len(groupIDs)) + for i, value := range groupIDs { + list[i] = value + } + return NewListQuery(GroupUsersColumnGroupID, list, ListIn) +} + +func (q *Queries) searchGroupUsers(ctx context.Context, queries *GroupUsersSearchQuery, permissionCheckV2 bool) (groupUsers *GroupUsers, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + query, scan := prepareGroupUsersQuery() + query = groupUsersPermissionCheckV2(ctx, query, queries, permissionCheckV2) + eq := sq.And{ + sq.Eq{ + GroupUsersColumnInstanceID.identifier(): authz.GetInstance(ctx).InstanceID(), + }, + } + stmt, args, err := queries.toQuery(query).Where(eq).ToSql() + if err != nil { + return nil, zerrors.ThrowInvalidArgument(err, "QUERY-TTlfF6", "Errors.Query.InvalidRequest") + } + + err = q.client.QueryContext(ctx, func(rows *sql.Rows) error { + groupUsers, err = scan(rows) + return err + }, stmt, args...) + if err != nil { + return nil, zerrors.ThrowInternal(err, "QUERY-M5O50l", "Errors.Internal") + } + groupUsers.State, err = q.latestState(ctx, groupUsersTable) + return groupUsers, nil +} + +func prepareGroupUsersQuery() (query sq.SelectBuilder, scan func(*sql.Rows) (*GroupUsers, error)) { + return sq.Select( + GroupUsersColumnGroupID.identifier(), + GroupUsersColumnUserID.identifier(), + HumanDisplayNameCol.identifier(), + LoginNameNameCol.identifier(), + GroupUsersColumnResourceOwner.identifier(), + HumanAvatarURLCol.identifier(), + GroupUsersColumnCreationDate.identifier(), + GroupUsersColumnSequence.identifier(), + countColumn.identifier(), + ).From(groupUsersTable.identifier()). + LeftJoin(join(HumanUserIDCol, GroupUsersColumnUserID)). + LeftJoin(join(LoginNameUserIDCol, GroupUsersColumnUserID)). + Where( + sq.Eq{LoginNameIsPrimaryCol.identifier(): true}, + ).PlaceholderFormat(sq.Dollar), + func(rows *sql.Rows) (*GroupUsers, error) { + groupUsers := make([]*GroupUser, 0) + var count uint64 + for rows.Next() { + g := new(GroupUser) + + var ( + displayName sql.NullString + avatarURL sql.NullString + preferredLoginName sql.NullString + ) + + err := rows.Scan( + &g.GroupID, + &g.UserID, + &displayName, + &preferredLoginName, + &g.ResourceOwner, + &avatarURL, + &g.CreationDate, + &g.Sequence, + &count, + ) + if err != nil { + return nil, err + } + + g.DisplayName = displayName.String + g.AvatarUrl = avatarURL.String + g.PreferredLoginName = preferredLoginName.String + groupUsers = append(groupUsers, g) + } + if err := rows.Close(); err != nil { + return nil, zerrors.ThrowInternal(err, "QUERY-JuX6i5", "Errors.Query.CloseRows") + } + return &GroupUsers{ + GroupUsers: groupUsers, + SearchResponse: SearchResponse{ + Count: count, + }, + }, nil + } +} + +func groupUsersCheckPermission(ctx context.Context, groupUsers *GroupUsers, permissionCheck domain.PermissionCheck) { + groupUsers.GroupUsers = slices.DeleteFunc(groupUsers.GroupUsers, + func(gu *GroupUser) bool { + return permissionCheck(ctx, domain.PermissionGroupUserRead, gu.ResourceOwner, gu.GroupID) != nil + }, + ) +} + +func groupUsersPermissionCheckV2(ctx context.Context, query sq.SelectBuilder, queries *GroupUsersSearchQuery, permissionCheckV2 bool) sq.SelectBuilder { + if !permissionCheckV2 { + return query + } + + join, args := PermissionClause( + ctx, + GroupUsersColumnResourceOwner, + domain.PermissionGroupUserRead, + ) + + return query.JoinClause(join, args...) +} + +func (q *GroupUsersSearchQuery) toQuery(query sq.SelectBuilder) sq.SelectBuilder { + query = q.SearchRequest.toQuery(query) + for _, q := range q.Queries { + query = q.toQuery(query) + } + return query +} diff --git a/internal/query/group_users_test.go b/internal/query/group_users_test.go new file mode 100644 index 00000000000..24a62f69591 --- /dev/null +++ b/internal/query/group_users_test.go @@ -0,0 +1,266 @@ +package query + +import ( + "context" + "database/sql" + "database/sql/driver" + "errors" + "fmt" + "regexp" + "testing" + + "github.com/stretchr/testify/require" +) + +var ( + groupUsersStmt = regexp.QuoteMeta( + "SELECT projections.group_users1.group_id" + + ", projections.group_users1.user_id" + + ", projections.users14_humans.display_name" + + ", projections.login_names3.login_name" + + ", projections.group_users1.resource_owner" + + ", projections.users14_humans.avatar_key" + + ", projections.group_users1.creation_date" + + ", projections.group_users1.sequence" + + ", COUNT(*) OVER ()" + + " FROM projections.group_users1" + + " LEFT JOIN projections.users14_humans ON projections.group_users1.user_id = projections.users14_humans.user_id AND projections.group_users1.instance_id = projections.users14_humans.instance_id" + + " LEFT JOIN projections.login_names3 ON projections.group_users1.user_id = projections.login_names3.user_id AND projections.group_users1.instance_id = projections.login_names3.instance_id" + + " WHERE projections.login_names3.is_primary = $1") + + groupUsersColumns = []string{ + "group_id", + "user_id", + "display_name", + "login_name", + "resource_owner", + "avatar_key", + "creation_date", + "sequence", + "count", + } +) + +func Test_GroupUsersPrepares(t *testing.T) { + t.Parallel() + type want struct { + sqlExpectations sqlExpectation + err checkErr + } + tests := []struct { + name string + prepare interface{} + want want + object interface{} + }{ + { + name: "prepareGroupUsersQuery no result", + prepare: prepareGroupUsersQuery, + want: want{ + sqlExpectations: mockQueries( + groupUsersStmt, + nil, + nil, + ), + }, + object: &GroupUsers{GroupUsers: []*GroupUser{}}, + }, + { + name: "prepareGroupUsersQuery with one result", + prepare: prepareGroupUsersQuery, + want: want{ + sqlExpectations: mockQueries( + groupUsersStmt, + groupUsersColumns, + [][]driver.Value{ + { + "group-id", + "user-id", + "display-name", + "login-name", + "resource-owner", + "avatar-key", + testNow, + 1, + }, + }, + ), + }, + object: &GroupUsers{ + SearchResponse: SearchResponse{ + Count: 1, + }, + GroupUsers: []*GroupUser{ + { + GroupID: "group-id", + UserID: "user-id", + ResourceOwner: "resource-owner", + CreationDate: testNow, + Sequence: 1, + PreferredLoginName: "login-name", + DisplayName: "display-name", + AvatarUrl: "avatar-key", + }, + }, + }, + }, + { + name: "prepareGroupUsersQuery with multiple results", + prepare: prepareGroupUsersQuery, + want: want{ + sqlExpectations: mockQueries( + groupUsersStmt, + groupUsersColumns, + [][]driver.Value{ + { + "group-id-1", + "user-id-1", + "display-name-1", + "login-name-1", + "resource-owner", + "avatar-key", + testNow, + 1, + }, + { + "group-id-1", + "user-id-2", + "display-name-2", + "login-name-2", + "resource-owner", + "avatar-key", + testNow, + 1, + }, + }, + ), + }, + object: &GroupUsers{ + SearchResponse: SearchResponse{ + Count: 2, + }, + GroupUsers: []*GroupUser{ + { + GroupID: "group-id-1", + UserID: "user-id-1", + ResourceOwner: "resource-owner", + CreationDate: testNow, + Sequence: 1, + PreferredLoginName: "login-name-1", + DisplayName: "display-name-1", + AvatarUrl: "avatar-key", + }, + { + GroupID: "group-id-1", + UserID: "user-id-2", + ResourceOwner: "resource-owner", + CreationDate: testNow, + Sequence: 1, + PreferredLoginName: "login-name-2", + DisplayName: "display-name-2", + AvatarUrl: "avatar-key", + }, + }, + }, + }, + { + name: "prepareGroupUsersQuery sql err", + prepare: prepareGroupUsersQuery, + want: want{ + sqlExpectations: mockQueryErr( + groupUsersStmt, + sql.ErrConnDone, + ), + err: func(err error) (error, bool) { + if !errors.Is(err, sql.ErrConnDone) { + return fmt.Errorf("err should be sql.ErrConnDone got: %w", err), false + } + return nil, true + }, + }, + object: (*GroupUsers)(nil), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assertPrepare(t, tt.prepare, tt.object, tt.want.sqlExpectations, tt.want.err) + }) + } +} + +func Test_GroupUsersCheckPermission(t *testing.T) { + t.Parallel() + tests := []struct { + name string + want []*GroupUser + groupUsers *GroupUsers + permissions []string + }{ + { + name: "no permissions", + want: []*GroupUser{}, + groupUsers: &GroupUsers{ + GroupUsers: []*GroupUser{ + {GroupID: "group1"}, {GroupID: "group2"}, {GroupID: "group3"}, + }, + }, + permissions: []string{}, + }, + { + name: "permissions for group1", + want: []*GroupUser{ + {GroupID: "group1", UserID: "user1"}, + }, + groupUsers: &GroupUsers{ + GroupUsers: []*GroupUser{ + {GroupID: "group1", UserID: "user1"}, {GroupID: "group2", UserID: "user2"}, {GroupID: "group3", UserID: "user3"}, + }, + }, + permissions: []string{"group1"}, + }, + { + name: "permissions for group2", + want: []*GroupUser{ + {GroupID: "group2", UserID: "user2"}, + }, + groupUsers: &GroupUsers{ + GroupUsers: []*GroupUser{ + {GroupID: "group1", UserID: "user1"}, {GroupID: "group2", UserID: "user2"}, {GroupID: "group3", UserID: "user3"}, + }, + }, + permissions: []string{"group2"}, + }, + { + name: "permissions for group1 and group2", + want: []*GroupUser{ + {GroupID: "group1", UserID: "user1"}, + {GroupID: "group2", UserID: "user1"}, + }, + groupUsers: &GroupUsers{ + GroupUsers: []*GroupUser{ + {GroupID: "group1", UserID: "user1"}, + {GroupID: "group2", UserID: "user1"}, + {GroupID: "group3", UserID: "user1"}, + {GroupID: "group4", UserID: "user2"}, + }, + }, + permissions: []string{"group1", "group2"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + checkPermission := func(ctx context.Context, permission, orgID, resourceID string) (err error) { + for _, perm := range tt.permissions { + if resourceID == perm { + return nil + } + } + return errors.New("not found") + } + groupUsersCheckPermission(context.Background(), tt.groupUsers, checkPermission) + require.Equal(t, tt.want, tt.groupUsers.GroupUsers) + }) + } +} diff --git a/internal/query/projection/group.go b/internal/query/projection/group.go index 10495c75726..5f25f13c3b0 100644 --- a/internal/query/projection/group.go +++ b/internal/query/projection/group.go @@ -75,7 +75,7 @@ func (g *groupProjection) Reducers() []handler.AggregateReducer { }, }, { - Aggregate: group.AggregateType, + Aggregate: org.AggregateType, EventReducers: []handler.EventReducer{ { Event: org.OrgRemovedEventType, @@ -145,6 +145,7 @@ func (g *groupProjection) reduceGroupChanged(event eventstore.Event) (*handler.S columns, []handler.Condition{ handler.NewCond(GroupColumnID, e.Aggregate().ID), + handler.NewCond(GroupColumnResourceOwner, e.Aggregate().ResourceOwner), handler.NewCond(GroupColumnInstanceID, e.Aggregate().InstanceID), }, ), nil @@ -159,6 +160,7 @@ func (g *groupProjection) reduceGroupRemoved(event eventstore.Event) (*handler.S e, []handler.Condition{ handler.NewCond(GroupColumnID, e.Aggregate().ID), + handler.NewCond(GroupColumnResourceOwner, e.Aggregate().ResourceOwner), handler.NewCond(GroupColumnInstanceID, e.Aggregate().InstanceID), }, ), nil diff --git a/internal/query/projection/group_test.go b/internal/query/projection/group_test.go new file mode 100644 index 00000000000..0bfb205d6be --- /dev/null +++ b/internal/query/projection/group_test.go @@ -0,0 +1,213 @@ +package projection + +import ( + "testing" + + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/eventstore/handler/v2" + "github.com/zitadel/zitadel/internal/repository/group" + "github.com/zitadel/zitadel/internal/repository/org" + "github.com/zitadel/zitadel/internal/zerrors" +) + +func Test_GroupReduces(t *testing.T) { + t.Parallel() + type args struct { + event func(t *testing.T) eventstore.Event + } + tests := []struct { + name string + args args + reduce func(event eventstore.Event) (*handler.Statement, error) + want wantReduce + }{ + { + name: "reduceGroupAdded", + args: args{ + event: getEvent( + testEvent( + group.GroupAddedEventType, + group.AggregateType, + []byte(`{ +"name": "group-name", +"description": "group-description" +}`), + ), + eventstore.GenericEventMapper[group.GroupAddedEvent], + ), + }, + reduce: (&groupProjection{}).reduceGroupAdded, + want: wantReduce{ + aggregateType: eventstore.AggregateType("group"), + sequence: 15, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "INSERT INTO projections.groups1 (id, name, resource_owner, instance_id, description, creation_date, change_date, sequence, state) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", + expectedArgs: []interface{}{ + "agg-id", + "group-name", + "ro-id", + "instance-id", + "group-description", + anyArg{}, + anyArg{}, + uint64(15), + domain.GroupStateActive, + }, + }, + }, + }, + }, + }, + { + name: "reduceGroupChanged name", + args: args{ + event: getEvent( + testEvent( + group.GroupChangedEventType, + group.AggregateType, + []byte(`{ +"name": "updated-group-name" +}`), + ), + eventstore.GenericEventMapper[group.GroupChangedEvent], + ), + }, + reduce: (&groupProjection{}).reduceGroupChanged, + want: wantReduce{ + aggregateType: eventstore.AggregateType("group"), + sequence: 15, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "UPDATE projections.groups1 SET (name, change_date, sequence) = ($1, $2, $3) WHERE (id = $4) AND (resource_owner = $5) AND (instance_id = $6)", + expectedArgs: []interface{}{ + "updated-group-name", + anyArg{}, + uint64(15), + "agg-id", + "ro-id", + "instance-id", + }, + }, + }, + }, + }, + }, + { + name: "reduceGroupChanged description", + args: args{ + event: getEvent( + testEvent( + group.GroupChangedEventType, + group.AggregateType, + []byte(`{ +"description": "updated-group-description" +}`), + ), + eventstore.GenericEventMapper[group.GroupChangedEvent], + ), + }, + reduce: (&groupProjection{}).reduceGroupChanged, + want: wantReduce{ + aggregateType: eventstore.AggregateType("group"), + sequence: 15, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "UPDATE projections.groups1 SET (description, change_date, sequence) = ($1, $2, $3) WHERE (id = $4) AND (resource_owner = $5) AND (instance_id = $6)", + expectedArgs: []interface{}{ + "updated-group-description", + anyArg{}, + uint64(15), + "agg-id", + "ro-id", + "instance-id", + }, + }, + }, + }, + }, + }, + { + name: "reduceGroupRemoved", + args: args{ + event: getEvent( + testEvent( + group.GroupRemovedEventType, + group.AggregateType, + []byte(`{ +"description": "updated-group-description" +}`), + ), + eventstore.GenericEventMapper[group.GroupRemovedEvent], + ), + }, + reduce: (&groupProjection{}).reduceGroupRemoved, + want: wantReduce{ + aggregateType: eventstore.AggregateType("group"), + sequence: 15, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "DELETE FROM projections.groups1 WHERE (id = $1) AND (resource_owner = $2) AND (instance_id = $3)", + expectedArgs: []interface{}{ + "agg-id", + "ro-id", + "instance-id", + }, + }, + }, + }, + }, + }, + { + name: "reduceOwnerRemoved", + args: args{ + event: getEvent( + testEvent( + org.OrgRemovedEventType, + org.AggregateType, + []byte(`{ +"description": "updated-group-description" +}`), + ), + org.OrgRemovedEventMapper, + ), + }, + reduce: (&groupProjection{}).reduceOwnerRemoved, + want: wantReduce{ + aggregateType: eventstore.AggregateType("org"), + sequence: 15, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "DELETE FROM projections.groups1 WHERE (instance_id = $1) AND (resource_owner = $2)", + expectedArgs: []interface{}{ + "instance-id", + "agg-id", + }, + }, + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + event := baseEvent(t) + got, err := tt.reduce(event) + if ok := zerrors.IsErrorInvalidArgument(err); !ok { + t.Errorf("no wrong event mapping: %v, got: %v", err, got) + } + + event = tt.args.event(t) + got, err = tt.reduce(event) + assertReduce(t, got, err, GroupProjectionTable, tt.want) + }) + } + +} diff --git a/internal/query/projection/group_users.go b/internal/query/projection/group_users.go new file mode 100644 index 00000000000..c5855e89b38 --- /dev/null +++ b/internal/query/projection/group_users.go @@ -0,0 +1,167 @@ +package projection + +import ( + "context" + + "github.com/zitadel/zitadel/internal/eventstore" + old_handler "github.com/zitadel/zitadel/internal/eventstore/handler" + "github.com/zitadel/zitadel/internal/eventstore/handler/v2" + "github.com/zitadel/zitadel/internal/repository/group" + "github.com/zitadel/zitadel/internal/repository/instance" + "github.com/zitadel/zitadel/internal/repository/org" +) + +const ( + GroupUsersProjectionTable = "projections.group_users1" + + GroupUsersColumnGroupID = "group_id" + GroupUsersColumnUserID = "user_id" + GroupUsersColumnResourceOwner = "resource_owner" + GroupUsersColumnInstanceID = "instance_id" + GroupUsersColumnSequence = "sequence" + GroupUsersColumnCreationDate = "creation_date" +) + +type groupUsersProjection struct{} + +func (g *groupUsersProjection) Name() string { + return GroupUsersProjectionTable +} + +func newGroupUsersProjection(ctx context.Context, config handler.Config) *handler.Handler { + return handler.NewHandler(ctx, &config, new(groupUsersProjection)) +} + +func (*groupUsersProjection) Init() *old_handler.Check { + return handler.NewTableCheck( + handler.NewTable([]*handler.InitColumn{ + handler.NewColumn(GroupUsersColumnGroupID, handler.ColumnTypeText), + handler.NewColumn(GroupUsersColumnUserID, handler.ColumnTypeText), + handler.NewColumn(GroupUsersColumnResourceOwner, handler.ColumnTypeText), + handler.NewColumn(GroupUsersColumnInstanceID, handler.ColumnTypeText), + handler.NewColumn(GroupUsersColumnSequence, handler.ColumnTypeInt64), + handler.NewColumn(GroupUsersColumnCreationDate, handler.ColumnTypeTimestamp), + }, + handler.NewPrimaryKey(GroupUsersColumnInstanceID, GroupUsersColumnGroupID, GroupUsersColumnUserID), + handler.WithIndex(handler.NewIndex("user_id", []string{GroupUsersColumnUserID})), + handler.WithIndex(handler.NewIndex("group_id", []string{GroupUsersColumnGroupID})), + ), + ) +} + +func (g *groupUsersProjection) Reducers() []handler.AggregateReducer { + return []handler.AggregateReducer{ + { + Aggregate: group.AggregateType, + EventReducers: []handler.EventReducer{ + { + Event: group.GroupUsersAddedEventType, + Reduce: g.reduceGroupUsersAdded, + }, + { + Event: group.GroupUsersRemovedEventType, + Reduce: g.reduceGroupUsersRemoved, + }, + }, + }, + { + Aggregate: group.AggregateType, + EventReducers: []handler.EventReducer{ + { + Event: group.GroupRemovedEventType, + Reduce: g.reduceGroupRemoved, + }, + }, + }, + { + Aggregate: org.AggregateType, + EventReducers: []handler.EventReducer{ + { + Event: org.OrgRemovedEventType, + Reduce: g.reduceOwnerRemoved, + }, + }, + }, + { + Aggregate: instance.AggregateType, + EventReducers: []handler.EventReducer{ + { + Event: instance.InstanceRemovedEventType, + Reduce: reduceInstanceRemovedHelper(GroupColumnInstanceID), + }, + }, + }, + } +} + +func (g *groupUsersProjection) reduceGroupUsersAdded(event eventstore.Event) (*handler.Statement, error) { + e, err := assertEvent[*group.GroupUsersAddedEvent](event) + if err != nil { + return nil, err + } + + stmts := make([]func(eventstore.Event) handler.Exec, 0, len(e.UserIDs)) + for _, userID := range e.UserIDs { + stmts = append(stmts, handler.AddCreateStatement( + []handler.Column{ + handler.NewCol(GroupUsersColumnGroupID, e.Aggregate().ID), + handler.NewCol(GroupUsersColumnUserID, userID), + handler.NewCol(GroupUsersColumnResourceOwner, e.Aggregate().ResourceOwner), + handler.NewCol(GroupUsersColumnInstanceID, e.Aggregate().InstanceID), + handler.NewCol(GroupUsersColumnSequence, e.Sequence()), + handler.NewCol(GroupUsersColumnCreationDate, e.CreationDate()), + }, + )) + } + return handler.NewMultiStatement(e, stmts...), nil +} + +func (g *groupUsersProjection) reduceGroupUsersRemoved(event eventstore.Event) (*handler.Statement, error) { + e, err := assertEvent[*group.GroupUsersRemovedEvent](event) + if err != nil { + return nil, err + } + stmts := make([]func(eventstore.Event) handler.Exec, 0, len(e.UserIDs)) + for _, userID := range e.UserIDs { + stmts = append(stmts, handler.AddDeleteStatement( + []handler.Condition{ + handler.NewCond(GroupUsersColumnGroupID, e.Aggregate().ID), + handler.NewCond(GroupUsersColumnUserID, userID), + handler.NewCond(GroupUsersColumnResourceOwner, e.Aggregate().ResourceOwner), + handler.NewCond(GroupUsersColumnInstanceID, e.Aggregate().InstanceID), + }, + )) + } + return handler.NewMultiStatement(e, stmts...), nil +} + +func (g *groupUsersProjection) reduceGroupRemoved(event eventstore.Event) (*handler.Statement, error) { + e, err := assertEvent[*group.GroupRemovedEvent](event) + if err != nil { + return nil, err + } + + return handler.NewDeleteStatement( + e, + []handler.Condition{ + handler.NewCond(GroupUsersColumnGroupID, e.Aggregate().ID), + handler.NewCond(GroupUsersColumnResourceOwner, e.Aggregate().ResourceOwner), + handler.NewCond(GroupUsersColumnInstanceID, e.Aggregate().InstanceID), + }, + ), nil +} + +func (g *groupUsersProjection) reduceOwnerRemoved(event eventstore.Event) (*handler.Statement, error) { + e, err := assertEvent[*org.OrgRemovedEvent](event) + if err != nil { + return nil, err + } + + return handler.NewDeleteStatement( + e, + []handler.Condition{ + handler.NewCond(GroupUsersColumnResourceOwner, e.Aggregate().ID), + handler.NewCond(GroupUsersColumnInstanceID, e.Aggregate().InstanceID), + }, + ), nil +} diff --git a/internal/query/projection/group_users_test.go b/internal/query/projection/group_users_test.go new file mode 100644 index 00000000000..d704e54dcc9 --- /dev/null +++ b/internal/query/projection/group_users_test.go @@ -0,0 +1,177 @@ +package projection + +import ( + "testing" + + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/eventstore/handler/v2" + "github.com/zitadel/zitadel/internal/repository/group" + "github.com/zitadel/zitadel/internal/repository/org" + "github.com/zitadel/zitadel/internal/zerrors" +) + +func Test_GroupUsersReduces(t *testing.T) { + t.Parallel() + type args struct { + event func(t *testing.T) eventstore.Event + } + tests := []struct { + name string + args args + reduce func(event eventstore.Event) (*handler.Statement, error) + want wantReduce + }{ + { + name: "reduceGroupUsersAdded", + args: args{ + event: getEvent( + testEvent( + group.GroupUsersAddedEventType, + group.AggregateType, + []byte( + `{ +"userIds": ["user-id-1", "user-id-2", "user-id-3"] +}`), + ), eventstore.GenericEventMapper[group.GroupUsersAddedEvent], + ), + }, + reduce: (&groupUsersProjection{}).reduceGroupUsersAdded, + want: wantReduce{ + aggregateType: eventstore.AggregateType("group"), + sequence: 15, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "INSERT INTO projections.group_users1 (group_id, user_id, resource_owner, instance_id, sequence, creation_date) VALUES ($1, $2, $3, $4, $5, $6)", + expectedArgs: []interface{}{ + "agg-id", + "user-id-1", + "ro-id", + "instance-id", + uint64(15), + anyArg{}, + }, + }, + { + expectedStmt: "INSERT INTO projections.group_users1 (group_id, user_id, resource_owner, instance_id, sequence, creation_date) VALUES ($1, $2, $3, $4, $5, $6)", + expectedArgs: []interface{}{ + "agg-id", + "user-id-2", + "ro-id", + "instance-id", + uint64(15), + anyArg{}, + }, + }, + { + expectedStmt: "INSERT INTO projections.group_users1 (group_id, user_id, resource_owner, instance_id, sequence, creation_date) VALUES ($1, $2, $3, $4, $5, $6)", + expectedArgs: []interface{}{ + "agg-id", + "user-id-3", + "ro-id", + "instance-id", + uint64(15), + anyArg{}, + }, + }, + }, + }, + }, + }, + { + name: "reduceGroupUsersRemoved", + args: args{ + event: getEvent( + testEvent( + group.GroupUsersRemovedEventType, + group.AggregateType, + []byte( + `{ +"userIds": ["user-id-1", "user-id-2"] +}`), + ), eventstore.GenericEventMapper[group.GroupUsersRemovedEvent], + ), + }, + reduce: (&groupUsersProjection{}).reduceGroupUsersRemoved, + want: wantReduce{ + aggregateType: eventstore.AggregateType("group"), + sequence: 15, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "DELETE FROM projections.group_users1 WHERE (group_id = $1) AND (user_id = $2) AND (resource_owner = $3) AND (instance_id = $4)", + expectedArgs: []interface{}{"agg-id", "user-id-1", "ro-id", "instance-id"}, + }, + { + expectedStmt: "DELETE FROM projections.group_users1 WHERE (group_id = $1) AND (user_id = $2) AND (resource_owner = $3) AND (instance_id = $4)", + expectedArgs: []interface{}{"agg-id", "user-id-2", "ro-id", "instance-id"}, + }, + }, + }, + }, + }, + { + name: "reduceGroupRemoved", + args: args{ + event: getEvent( + testEvent( + group.GroupRemovedEventType, + group.AggregateType, + nil, + ), eventstore.GenericEventMapper[group.GroupRemovedEvent], + ), + }, + reduce: (&groupUsersProjection{}).reduceGroupRemoved, + want: wantReduce{ + aggregateType: eventstore.AggregateType("group"), + sequence: 15, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "DELETE FROM projections.group_users1 WHERE (group_id = $1) AND (resource_owner = $2) AND (instance_id = $3)", + expectedArgs: []interface{}{"agg-id", "ro-id", "instance-id"}, + }, + }, + }, + }, + }, + { + name: "reduceOwnerRemoved", + args: args{ + event: getEvent( + testEvent( + org.OrgRemovedEventType, + org.AggregateType, + nil, + ), org.OrgRemovedEventMapper, + ), + }, + reduce: (&groupUsersProjection{}).reduceOwnerRemoved, + want: wantReduce{ + aggregateType: eventstore.AggregateType("org"), + sequence: 15, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "DELETE FROM projections.group_users1 WHERE (resource_owner = $1) AND (instance_id = $2)", + expectedArgs: []interface{}{"agg-id", "instance-id"}, + }, + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + event := baseEvent(t) + got, err := tt.reduce(event) + if ok := zerrors.IsErrorInvalidArgument(err); !ok { + t.Errorf("no wrong event mapping: %v, got: %v", err, got) + } + + event = tt.args.event(t) + got, err = tt.reduce(event) + assertReduce(t, got, err, GroupUsersProjectionTable, tt.want) + }) + } +} diff --git a/internal/query/projection/projection.go b/internal/query/projection/projection.go index e78029d432e..550899e56c4 100644 --- a/internal/query/projection/projection.go +++ b/internal/query/projection/projection.go @@ -104,7 +104,8 @@ var ( MembershipFields *handler.FieldHandler PermissionFields *handler.FieldHandler - GroupProjection *handler.Handler + GroupProjection *handler.Handler + GroupUsersProjection *handler.Handler ) type projection interface { @@ -211,6 +212,7 @@ func Create(ctx context.Context, sqlClient *database.DB, es handler.EventStore, // Don't forget to add the new field handler to [ProjectInstanceFields] GroupProjection = newGroupProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["groups"])) + GroupUsersProjection = newGroupUsersProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["group_users"])) InstanceRelationalProjection = newInstanceRelationalProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["instances_relational"])) OrganizationRelationalProjection = newOrgRelationalProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["organizations_relational"])) @@ -403,6 +405,7 @@ func newProjectionsList() { HostedLoginTranslationProjection, OrganizationSettingsProjection, GroupProjection, + GroupUsersProjection, InstanceRelationalProjection, OrganizationRelationalProjection, diff --git a/internal/repository/group/eventstore.go b/internal/repository/group/eventstore.go index 60e3f92cae6..e504e2c5261 100644 --- a/internal/repository/group/eventstore.go +++ b/internal/repository/group/eventstore.go @@ -6,4 +6,6 @@ func init() { eventstore.RegisterFilterEventMapper(AggregateType, GroupAddedEventType, eventstore.GenericEventMapper[GroupAddedEvent]) eventstore.RegisterFilterEventMapper(AggregateType, GroupChangedEventType, eventstore.GenericEventMapper[GroupChangedEvent]) eventstore.RegisterFilterEventMapper(AggregateType, GroupRemovedEventType, eventstore.GenericEventMapper[GroupRemovedEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, GroupUsersAddedEventType, eventstore.GenericEventMapper[GroupUsersAddedEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, GroupUsersRemovedEventType, eventstore.GenericEventMapper[GroupUsersRemovedEvent]) } diff --git a/internal/repository/group/user.go b/internal/repository/group/user.go new file mode 100644 index 00000000000..bd079a60485 --- /dev/null +++ b/internal/repository/group/user.go @@ -0,0 +1,74 @@ +package group + +import ( + "context" + + "github.com/zitadel/zitadel/internal/eventstore" +) + +const ( + GroupUsersAddedEventType = groupEventTypePrefix + "users.added" + GroupUsersRemovedEventType = groupEventTypePrefix + "users.removed" +) + +type GroupUsersAddedEvent struct { + eventstore.BaseEvent `json:"-"` + + UserIDs []string `json:"userIds"` +} + +func NewGroupUsersAddedEvent(ctx context.Context, aggregate *eventstore.Aggregate, userIDs []string) *GroupUsersAddedEvent { + return &GroupUsersAddedEvent{ + BaseEvent: *eventstore.NewBaseEventForPush( + ctx, + aggregate, + GroupUsersAddedEventType, + ), + UserIDs: userIDs, + } +} + +func (e *GroupUsersAddedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = *event +} + +func (e *GroupUsersAddedEvent) Payload() interface{} { + return e +} + +func (e *GroupUsersAddedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + return nil +} + +type GroupUsersRemovedEvent struct { + eventstore.BaseEvent `json:"-"` + + UserIDs []string `json:"userIds"` +} + +func NewGroupUsersRemovedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, + userIDs []string, +) *GroupUsersRemovedEvent { + return &GroupUsersRemovedEvent{ + BaseEvent: *eventstore.NewBaseEventForPush( + ctx, + aggregate, + GroupUsersRemovedEventType, + ), + UserIDs: userIDs, + } +} + +func (e *GroupUsersRemovedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = *event +} + +func (e *GroupUsersRemovedEvent) Payload() interface{} { + return e +} + +func (e *GroupUsersRemovedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + return nil +} diff --git a/proto/zitadel/group/v2/group.proto b/proto/zitadel/group/v2/group.proto index 90b9df6d3e9..0c4ce2c94ea 100644 --- a/proto/zitadel/group/v2/group.proto +++ b/proto/zitadel/group/v2/group.proto @@ -5,6 +5,7 @@ import "protoc-gen-openapiv2/options/annotations.proto"; import "validate/validate.proto"; import "zitadel/filter/v2/filter.proto"; +import "zitadel/authorization/v2beta/authorization.proto"; package zitadel.group.v2; @@ -54,6 +55,32 @@ message Group { ]; } +message GroupUser { + // GroupID is the unique identifier of the user group. + string group_id = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"69629023906488334\"" + } + ]; + + // OrganizationID is the unique identifier of the organization to which the group belongs. + string organization_id = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"69629098906488334\"" + } + ]; + + // User represents the user in the user group. + zitadel.authorization.v2beta.User user = 3; + + // CreationDate is the timestamp when the user was added to the group. + google.protobuf.Timestamp creation_date = 4 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; +} + message GroupsSearchFilter { oneof filter { option (validate.required) = true; @@ -94,4 +121,22 @@ enum FieldName { FIELD_NAME_NAME = 2; FIELD_NAME_CREATION_DATE = 3; FIELD_NAME_CHANGE_DATE = 4; +} + +message GroupUsersSearchFilter { + oneof filter { + option (validate.required) = true; + + // Search for groups by user IDs + zitadel.filter.v2.InIDsFilter user_ids = 1; + // Search for users in groups by group IDs + zitadel.filter.v2.InIDsFilter group_ids = 2; + } +} + +enum GroupUserFieldName { + GROUP_USER_FIELD_NAME_UNSPECIFIED = 0; + GROUP_USER_FIELD_NAME_USER_ID = 1; + GROUP_USER_FIELD_NAME_GROUP_ID = 2; + GROUP_USER_FIELD_NAME_CREATION_DATE = 3; } \ No newline at end of file diff --git a/proto/zitadel/group/v2/group_service.proto b/proto/zitadel/group/v2/group_service.proto index e248a1f3152..1d6d194d336 100644 --- a/proto/zitadel/group/v2/group_service.proto +++ b/proto/zitadel/group/v2/group_service.proto @@ -298,6 +298,53 @@ service GroupService { }; }; } + + // Add Users + // + // Adds one or more users to a group. + // The users should be in the same organization as the group. + // The request will not fail if some of the users could not be added to the group, + // and a list of failed user IDs is available in the response. + // + // Required permissions: + // - group.user.write + rpc AddUsersToGroup(AddUsersToGroupRequest) returns (AddUsersToGroupResponse) { + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + } + + // Remove Users + // + // Removes one or more users from a group. + // If some of the users are not in the group, the request will still return a successful response as + // the desired state is already achieved. + // + // Required permissions: + // - group.user.delete + rpc RemoveUsersFromGroup(RemoveUsersFromGroupRequest) returns (RemoveUsersFromGroupResponse) { + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + } + + // List Group Users + // + // Allows searching for groups based on user IDs, or retrieving users based on group IDs + // + // Required permissions: + // - group.user.read + rpc ListGroupUsers(ListGroupUsersRequest) returns (ListGroupUsersResponse) { + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + } } message CreateGroupRequest { @@ -460,4 +507,81 @@ message DeleteGroupResponse { example: "\"2025-08-11T15:00:00.051Z\""; } ]; -} \ No newline at end of file +} + +message AddUsersToGroupRequest { + // ID of the group to which the users should be added. + string id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"69629012906488334\""; + }, + (google.api.field_behavior) = REQUIRED + ]; + + // UserIds is a list of IDs of the users who should be added to the group. + repeated string user_ids = 2 [ + (validate.rules).repeated = {min_items: 1, items: {string: {min_len: 1, max_len: 200}}}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "[\"69629023906488334\",\"69622366012355662\"]"; + } + ]; +} + +message AddUsersToGroupResponse { + // ChangeDate is the timestamp when the users are added. + google.protobuf.Timestamp change_date = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-08-11T15:00:00.051Z\""; + } + ]; +} + +message RemoveUsersFromGroupRequest { + // ID of the group from which the users should be removed. + string id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"69629012906488334\""; + }, + (google.api.field_behavior) = REQUIRED + ]; + + // UserIds is a list of IDs of the users who should be removed the group. + repeated string user_ids = 2 [ + (validate.rules).repeated = {min_items: 1, items: {string: {min_len: 1, max_len: 200}}}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "[\"69629023906488334\",\"69622366012355662\"]"; + } + ]; +} + +message RemoveUsersFromGroupResponse { + // ChangeDate is the timestamp when the users are removed. + google.protobuf.Timestamp change_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-08-11T15:00:00.051Z\""; + } + ]; +} + +message ListGroupUsersRequest { + // GroupUsersSearchFilter defines the criteria to list the user groups. + repeated GroupUsersSearchFilter filters = 1; + + // Pagination and sorting. + zitadel.filter.v2.PaginationRequest pagination = 2; + + // SortingColumn defines the field the result is sorted by. + optional GroupUserFieldName sorting_column = 3; +} + +message ListGroupUsersResponse { + // GroupUsers is the list of users present in the requested group, matching the search criteria + repeated GroupUser group_users = 1; + + // Contains the total number of users matching the query and the applied limit. + zitadel.filter.v2.PaginationResponse pagination = 2; +}