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
This commit is contained in:
Gayathri Vijayan
2025-10-28 14:23:54 +01:00
committed by GitHub
parent c2a0b9d187
commit ad8e8bf61f
34 changed files with 3825 additions and 88 deletions

View File

@@ -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:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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

View File

@@ -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) {

View File

@@ -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

View File

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

View File

@@ -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.

View File

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

View File

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

View File

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

View File

@@ -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

View File

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

View File

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

View File

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

View File

@@ -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,

View File

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

View File

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

View File

@@ -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;
}

View File

@@ -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\"";
}
];
}
}
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;
}