mirror of
https://github.com/zitadel/zitadel.git
synced 2025-12-24 03:06:47 +00:00
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:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
30
internal/api/grpc/group/v2/group_users.go
Normal file
30
internal/api/grpc/group/v2/group_users.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -320,7 +310,7 @@ func TestServer_DeleteGroup(t *testing.T) {
|
||||
ctx context.Context
|
||||
req *group_v2.DeleteGroupRequest
|
||||
wantErrCode codes.Code
|
||||
wantErrMsg string
|
||||
wantDeletionDate bool
|
||||
deletionTime *timestamp.Timestamp
|
||||
}{
|
||||
{
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
323
internal/api/grpc/group/v2/integration_test/group_users_test.go
Normal file
323
internal/api/grpc/group/v2/integration_test/group_users_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
131
internal/command/group_users.go
Normal file
131
internal/command/group_users.go
Normal 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
|
||||
}
|
||||
541
internal/command/group_users_test.go
Normal file
541
internal/command/group_users_test.go
Normal 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,
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
220
internal/query/group_users.go
Normal file
220
internal/query/group_users.go
Normal 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
|
||||
}
|
||||
266
internal/query/group_users_test.go
Normal file
266
internal/query/group_users_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
213
internal/query/projection/group_test.go
Normal file
213
internal/query/projection/group_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
167
internal/query/projection/group_users.go
Normal file
167
internal/query/projection/group_users.go
Normal 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
|
||||
}
|
||||
177
internal/query/projection/group_users_test.go
Normal file
177
internal/query/projection/group_users_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -105,6 +105,7 @@ var (
|
||||
PermissionFields *handler.FieldHandler
|
||||
|
||||
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,
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
74
internal/repository/group/user.go
Normal file
74
internal/repository/group/user.go
Normal 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
|
||||
}
|
||||
@@ -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;
|
||||
@@ -95,3 +122,21 @@ enum FieldName {
|
||||
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;
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -461,3 +508,80 @@ message DeleteGroupResponse {
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user