feat(group): group service to create, update, and delete groups (#10455)

# Which Problems Are Solved

This PR adds API definition and backend implementation for GroupService
to manage user groups.

# How the Problems Are Solved
* API definition to create, update, retrieve, and delete groups is added
* Command-side implementation to create, update, and delete user groups
as part of the GroupV2 API is added

# Additional Changes
N/A

# Additional Context
- Related to #10089, #9702 (parent ticket)
- User contribution: https://github.com/zitadel/zitadel/pull/9428/files
- Additional functionalities to list/search user groups, add
permissions, manage users in groups, group scopes will be added in
subsequent PRs.
- Also needs documentation, which will be added once the entire feature
is available

---------

Co-authored-by: Livio Spring <[email protected]>
This commit is contained in:
Gayathri Vijayan
2025-10-06 11:23:15 +02:00
committed by GitHub
co-authored by Livio Spring
parent ee3654fe6f
commit b81dedcaea
18 changed files with 2349 additions and 0 deletions
+63
View File
@@ -0,0 +1,63 @@
package group
import (
"context"
"connectrpc.com/connect"
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/zitadel/zitadel/internal/command"
"github.com/zitadel/zitadel/internal/eventstore/v1/models"
group_v2 "github.com/zitadel/zitadel/pkg/grpc/group/v2"
)
// CreateGroup creates a new user group in the specified organization.
func (s *Server) CreateGroup(ctx context.Context, req *connect.Request[group_v2.CreateGroupRequest]) (*connect.Response[group_v2.CreateGroupResponse], error) {
userGroup := &command.CreateGroup{
ObjectRoot: models.ObjectRoot{
AggregateID: req.Msg.GetId(),
ResourceOwner: req.Msg.GetOrganizationId(),
},
Name: req.Msg.GetName(),
Description: req.Msg.GetDescription(),
}
groupDetails, err := s.command.CreateGroup(ctx, userGroup)
if err != nil {
return nil, err
}
return connect.NewResponse(&group_v2.CreateGroupResponse{
Id: groupDetails.ID,
CreationDate: timestamppb.New(groupDetails.EventDate),
}), nil
}
// UpdateGroup updates a user group.
func (s *Server) UpdateGroup(ctx context.Context, req *connect.Request[group_v2.UpdateGroupRequest]) (*connect.Response[group_v2.UpdateGroupResponse], error) {
userGroup := &command.UpdateGroup{
ObjectRoot: models.ObjectRoot{
AggregateID: req.Msg.GetId(),
},
Name: req.Msg.Name,
Description: req.Msg.Description,
}
details, err := s.command.UpdateGroup(ctx, userGroup)
if err != nil {
return nil, err
}
return connect.NewResponse(&group_v2.UpdateGroupResponse{
ChangeDate: timestamppb.New(details.EventDate),
}), nil
}
// DeleteGroup deletes a user group from an organization.
func (s *Server) DeleteGroup(ctx context.Context, req *connect.Request[group_v2.DeleteGroupRequest]) (*connect.Response[group_v2.DeleteGroupResponse], error) {
details, err := s.command.DeleteGroup(ctx, req.Msg.GetId())
if err != nil {
return nil, err
}
return connect.NewResponse(&group_v2.DeleteGroupResponse{
DeletionDate: timestamppb.New(details.EventDate),
}), nil
}
@@ -0,0 +1,287 @@
//go:build integration
package group_test
import (
"context"
"testing"
"github.com/golang/protobuf/ptypes/timestamp"
"github.com/muhlemmer/gu"
"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_CreateGroup(t *testing.T) {
iamOwnerCtx := instance.WithAuthorizationToken(CTX, integration.UserTypeIAMOwner)
orgResp := instance.CreateOrganization(iamOwnerCtx, integration.OrganizationName(), integration.Email())
alreadyExistingGroupName := integration.GroupName()
resp := instance.CreateGroup(iamOwnerCtx, t, orgResp.GetOrganizationId(), alreadyExistingGroupName)
tests := []struct {
name string
ctx context.Context
req *group_v2.CreateGroupRequest
wantResp bool
wantGroupID string
wantErrorCode codes.Code
wantErrMsg string
}{
{
name: "invalid name, error",
ctx: iamOwnerCtx,
req: &group_v2.CreateGroupRequest{
Name: " ",
OrganizationId: "org1",
},
wantErrorCode: codes.InvalidArgument,
wantErrMsg: "Errors.Group.InvalidName (GROUP-m177lN)",
},
{
name: "missing organization id, error",
ctx: iamOwnerCtx,
req: &group_v2.CreateGroupRequest{
Name: integration.GroupName(),
},
wantErrorCode: codes.InvalidArgument,
wantErrMsg: "invalid CreateGroupRequest.OrganizationId: value length must be between 1 and 200 runes, inclusive",
},
{
name: "organization not found, error",
ctx: iamOwnerCtx,
req: &group_v2.CreateGroupRequest{
Name: integration.GroupName(),
OrganizationId: "org1",
},
wantErrorCode: codes.FailedPrecondition,
wantErrMsg: "Organisation not found (CMDGRP-j1mH8l)",
},
{
name: "already existing group (unique name constraint), error",
ctx: iamOwnerCtx,
req: &group_v2.CreateGroupRequest{
Name: alreadyExistingGroupName,
OrganizationId: orgResp.GetOrganizationId(),
},
wantErrorCode: codes.AlreadyExists,
wantErrMsg: "Errors.Group.AlreadyExists (V3-DKcYh)",
},
{
name: "already existing group ID, error",
ctx: iamOwnerCtx,
req: &group_v2.CreateGroupRequest{
Id: gu.Ptr(resp.Id),
Name: integration.GroupName(),
OrganizationId: orgResp.GetOrganizationId(),
},
wantErrorCode: codes.AlreadyExists,
wantErrMsg: "Errors.Group.AlreadyExists (CMDGRP-shRut3)",
},
{
name: "create group with ID, ok",
ctx: iamOwnerCtx,
req: &group_v2.CreateGroupRequest{
Id: gu.Ptr("1234"),
Name: integration.GroupName(),
OrganizationId: orgResp.GetOrganizationId(),
},
wantResp: true,
wantGroupID: "1234",
},
{
name: "create group without user provided ID, ok",
ctx: iamOwnerCtx,
req: &group_v2.CreateGroupRequest{
Name: integration.GroupName(),
OrganizationId: orgResp.GetOrganizationId(),
},
wantResp: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := instance.Client.GroupV2.CreateGroup(tt.ctx, tt.req)
if tt.wantErrorCode != codes.OK {
require.Error(t, err)
require.Empty(t, got.GetId())
require.Empty(t, got.GetCreationDate())
assert.Equal(t, tt.wantErrorCode, status.Code(err))
assert.Equal(t, tt.wantErrMsg, status.Convert(err).Message())
return
}
require.NoError(t, err)
if tt.wantGroupID != "" {
assert.Equal(t, tt.wantGroupID, got.Id, "want: %v, got: %v", tt.wantGroupID, got)
}
require.NotEmpty(t, got.GetId())
require.NotEmpty(t, got.GetCreationDate())
})
}
}
func TestServer_UpdateGroup(t *testing.T) {
iamOwnerCtx := instance.WithAuthorizationToken(CTX, integration.UserTypeIAMOwner)
orgResp := instance.CreateOrganization(iamOwnerCtx, integration.OrganizationName(), integration.Email())
groupName := integration.GroupName()
existingGroup := instance.CreateGroup(iamOwnerCtx, t, orgResp.GetOrganizationId(), groupName)
tests := []struct {
name string
ctx context.Context
req *group_v2.UpdateGroupRequest
wantChangeDate bool
wantErrCode codes.Code
wantErrMsg string
}{
{
name: "invalid name, error",
ctx: iamOwnerCtx,
req: &group_v2.UpdateGroupRequest{
Id: existingGroup.GetId(),
Name: gu.Ptr(" "),
},
wantErrCode: codes.InvalidArgument,
wantErrMsg: "Errors.Group.InvalidName (GROUP-dUNd3r)",
},
{
name: "group not found, error",
ctx: iamOwnerCtx,
req: &group_v2.UpdateGroupRequest{
Id: "12345",
Name: gu.Ptr("updated group name"),
},
wantErrCode: codes.NotFound,
wantErrMsg: "Errors.Group.NotFound (CMDGRP-b33zly)",
},
{
name: "no change, ok",
ctx: iamOwnerCtx,
req: &group_v2.UpdateGroupRequest{
Id: existingGroup.GetId(),
Name: gu.Ptr(groupName),
},
wantChangeDate: true,
},
{
name: "change name, ok",
ctx: iamOwnerCtx,
req: &group_v2.UpdateGroupRequest{
Id: existingGroup.GetId(),
Name: gu.Ptr("groupXX"),
},
wantChangeDate: true,
},
{
name: "change description, ok",
ctx: iamOwnerCtx,
req: &group_v2.UpdateGroupRequest{
Id: existingGroup.GetId(),
Description: gu.Ptr("description of groupXX"),
},
wantChangeDate: true,
},
{
name: "full change, ok",
ctx: iamOwnerCtx,
req: &group_v2.UpdateGroupRequest{
Id: existingGroup.GetId(),
Name: gu.Ptr("groupXXX"),
Description: gu.Ptr("description of groupXXX"),
},
wantChangeDate: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := instance.Client.GroupV2.UpdateGroup(tt.ctx, tt.req)
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())
})
}
}
func TestServer_DeleteGroup(t *testing.T) {
iamOwnerCtx := instance.WithAuthorizationToken(CTX, integration.UserTypeIAMOwner)
orgResp := instance.CreateOrganization(iamOwnerCtx, integration.OrganizationName(), integration.Email())
groupName := integration.GroupName()
existingGroup := instance.CreateGroup(iamOwnerCtx, t, orgResp.GetOrganizationId(), groupName)
deleteGroupName := integration.GroupName()
deleteGroup := instance.CreateGroup(iamOwnerCtx, t, orgResp.GetOrganizationId(), deleteGroupName)
deleteResp := instance.DeleteGroup(iamOwnerCtx, t, deleteGroup.GetId())
tests := []struct {
name string
ctx context.Context
req *group_v2.DeleteGroupRequest
wantErrCode codes.Code
wantErrMsg string
deletionTime *timestamp.Timestamp
}{
{
name: "missing id, error",
ctx: iamOwnerCtx,
req: &group_v2.DeleteGroupRequest{
Id: "",
},
wantErrCode: codes.InvalidArgument,
wantErrMsg: "invalid DeleteGroupRequest.Id: value length must be between 1 and 200 runes, inclusive",
},
{
name: "group not found, ok",
ctx: iamOwnerCtx,
req: &group_v2.DeleteGroupRequest{
Id: "12345",
},
},
{
name: "delete, ok",
ctx: iamOwnerCtx,
req: &group_v2.DeleteGroupRequest{
Id: existingGroup.GetId(),
},
},
{
name: "delete already deleted group, ok",
ctx: iamOwnerCtx,
req: &group_v2.DeleteGroupRequest{
Id: deleteGroup.GetId(),
},
deletionTime: deleteResp.GetDeletionDate(),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := instance.Client.GroupV2.DeleteGroup(tt.ctx, tt.req)
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)
require.NotEmpty(t, got.GetDeletionDate())
if tt.deletionTime != nil {
assert.Equal(t, tt.deletionTime, got.GetDeletionDate())
}
})
}
}
@@ -0,0 +1,27 @@
//go:build integration
package group_test
import (
"context"
"os"
"testing"
"time"
"github.com/zitadel/zitadel/internal/integration"
)
var (
CTX context.Context
instance *integration.Instance
)
func TestMain(m *testing.M) {
os.Exit(func() int {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
defer cancel()
CTX = ctx
instance = integration.NewInstance(ctx)
return m.Run()
}())
}
+45
View File
@@ -0,0 +1,45 @@
package group
import (
"context"
"connectrpc.com/connect"
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/zitadel/zitadel/internal/query"
"github.com/zitadel/zitadel/internal/zerrors"
group "github.com/zitadel/zitadel/pkg/grpc/group/v2"
)
// GetGroup returns a group that matches the group ID in the request
func (s *Server) GetGroup(ctx context.Context, req *connect.Request[group.GetGroupRequest]) (*connect.Response[group.GetGroupResponse], error) {
return nil, zerrors.ThrowUnimplemented(nil, "GRP-1234", "Errors.Internal.Unimplemented")
}
// ListGroups returns a list of groups that match the search criteria
func (s *Server) ListGroups(ctx context.Context, req *connect.Request[group.ListGroupsRequest]) (*connect.Response[group.ListGroupsResponse], error) {
resp, err := s.query.SearchGroups(ctx)
if err != nil {
return nil, err
}
return connect.NewResponse(&group.ListGroupsResponse{
Groups: groupsToPb(resp.Groups),
}), nil
}
func groupsToPb(groups []*query.Group) []*group.Group {
pbGroups := make([]*group.Group, len(groups))
for i, g := range groups {
pbGroups[i] = groupToPb(g)
}
return pbGroups
}
func groupToPb(g *query.Group) *group.Group {
return &group.Group{
Id: g.ID,
Name: g.Name,
CreationDate: timestamppb.New(g.CreationDate),
ChangeDate: timestamppb.New(g.ChangeDate),
}
}
+60
View File
@@ -0,0 +1,60 @@
package group
import (
"net/http"
"connectrpc.com/connect"
"google.golang.org/protobuf/reflect/protoreflect"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/command"
"github.com/zitadel/zitadel/internal/config/systemdefaults"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/query"
group_v2 "github.com/zitadel/zitadel/pkg/grpc/group/v2"
"github.com/zitadel/zitadel/pkg/grpc/group/v2/groupconnect"
)
var _ groupconnect.GroupServiceHandler = (*Server)(nil)
type Server struct {
systemDefaults systemdefaults.SystemDefaults
command *command.Commands
query *query.Queries
checkPermission domain.PermissionCheck
}
func CreateServer(
systemDefaults systemdefaults.SystemDefaults,
command *command.Commands,
query *query.Queries,
checkPermission domain.PermissionCheck,
) *Server {
return &Server{
systemDefaults: systemDefaults,
command: command,
query: query,
checkPermission: checkPermission,
}
}
func (s *Server) RegisterConnectServer(interceptors ...connect.Interceptor) (string, http.Handler) {
return groupconnect.NewGroupServiceHandler(s, connect.WithInterceptors(interceptors...))
}
func (s *Server) FileDescriptor() protoreflect.FileDescriptor {
return group_v2.File_zitadel_group_v2_group_service_proto
}
func (s *Server) AppName() string {
return group_v2.GroupService_ServiceDesc.ServiceName
}
func (s *Server) MethodPrefix() string {
return group_v2.GroupService_ServiceDesc.ServiceName
}
func (s *Server) AuthMethods() authz.MethodMapping {
return group_v2.GroupService_AuthMethods
}