From b81dedcaeadb0a6a8ac01429edf5e7250b0a7a20 Mon Sep 17 00:00:00 2001 From: Gayathri Vijayan <66356931+grvijayan@users.noreply.github.com> Date: Mon, 6 Oct 2025 11:23:15 +0200 Subject: [PATCH] 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 --- cmd/start/start.go | 4 + internal/api/grpc/group/v2/group.go | 63 ++ .../group/v2/integration_test/group_test.go | 287 +++++++ .../group/v2/integration_test/server_test.go | 27 + internal/api/grpc/group/v2/query.go | 45 + internal/api/grpc/group/v2/server.go | 60 ++ internal/command/group.go | 160 ++++ internal/command/group_model.go | 87 ++ internal/command/group_test.go | 770 ++++++++++++++++++ internal/domain/group.go | 40 + internal/integration/client.go | 20 + internal/integration/name.go | 4 + internal/query/group.go | 27 + internal/repository/group/aggregate.go | 27 + internal/repository/group/eventstore.go | 9 + internal/repository/group/group.go | 155 ++++ proto/zitadel/group/v2/group.proto | 97 +++ proto/zitadel/group/v2/group_service.proto | 467 +++++++++++ 18 files changed, 2349 insertions(+) create mode 100644 internal/api/grpc/group/v2/group.go create mode 100644 internal/api/grpc/group/v2/integration_test/group_test.go create mode 100644 internal/api/grpc/group/v2/integration_test/server_test.go create mode 100644 internal/api/grpc/group/v2/query.go create mode 100644 internal/api/grpc/group/v2/server.go create mode 100644 internal/command/group.go create mode 100644 internal/command/group_model.go create mode 100644 internal/command/group_test.go create mode 100644 internal/domain/group.go create mode 100644 internal/query/group.go create mode 100644 internal/repository/group/aggregate.go create mode 100644 internal/repository/group/eventstore.go create mode 100644 internal/repository/group/group.go create mode 100644 proto/zitadel/group/v2/group.proto create mode 100644 proto/zitadel/group/v2/group_service.proto diff --git a/cmd/start/start.go b/cmd/start/start.go index 8434b4476..1378779c1 100644 --- a/cmd/start/start.go +++ b/cmd/start/start.go @@ -43,6 +43,7 @@ import ( authorization_v2beta "github.com/zitadel/zitadel/internal/api/grpc/authorization/v2beta" feature_v2 "github.com/zitadel/zitadel/internal/api/grpc/feature/v2" feature_v2beta "github.com/zitadel/zitadel/internal/api/grpc/feature/v2beta" + group_v2 "github.com/zitadel/zitadel/internal/api/grpc/group/v2" idp_v2 "github.com/zitadel/zitadel/internal/api/grpc/idp/v2" instance "github.com/zitadel/zitadel/internal/api/grpc/instance/v2beta" internal_permission_v2beta "github.com/zitadel/zitadel/internal/api/grpc/internal_permission/v2beta" @@ -556,6 +557,9 @@ func startAPIs( if err := apis.RegisterService(ctx, app.CreateServer(commands, queries, permissionCheck)); err != nil { return nil, err } + if err := apis.RegisterService(ctx, group_v2.CreateServer(config.SystemDefaults, commands, queries, permissionCheck)); err != nil { + return nil, err + } instanceInterceptor := middleware.InstanceInterceptor(queries, config.ExternalDomain, translator, login.IgnoreInstanceEndpoints...) assetsCache := middleware.AssetsCacheInterceptor(config.AssetStorage.Cache.MaxAge, config.AssetStorage.Cache.SharedMaxAge) diff --git a/internal/api/grpc/group/v2/group.go b/internal/api/grpc/group/v2/group.go new file mode 100644 index 000000000..1babeeb75 --- /dev/null +++ b/internal/api/grpc/group/v2/group.go @@ -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 +} diff --git a/internal/api/grpc/group/v2/integration_test/group_test.go b/internal/api/grpc/group/v2/integration_test/group_test.go new file mode 100644 index 000000000..713db5aee --- /dev/null +++ b/internal/api/grpc/group/v2/integration_test/group_test.go @@ -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()) + } + }) + } +} diff --git a/internal/api/grpc/group/v2/integration_test/server_test.go b/internal/api/grpc/group/v2/integration_test/server_test.go new file mode 100644 index 000000000..bef6d603c --- /dev/null +++ b/internal/api/grpc/group/v2/integration_test/server_test.go @@ -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() + }()) +} diff --git a/internal/api/grpc/group/v2/query.go b/internal/api/grpc/group/v2/query.go new file mode 100644 index 000000000..859b438ca --- /dev/null +++ b/internal/api/grpc/group/v2/query.go @@ -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), + } +} diff --git a/internal/api/grpc/group/v2/server.go b/internal/api/grpc/group/v2/server.go new file mode 100644 index 000000000..ea165ec4a --- /dev/null +++ b/internal/api/grpc/group/v2/server.go @@ -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 +} diff --git a/internal/command/group.go b/internal/command/group.go new file mode 100644 index 000000000..406bf5e6b --- /dev/null +++ b/internal/command/group.go @@ -0,0 +1,160 @@ +package command + +import ( + "context" + "strings" + + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore/v1/models" + repo "github.com/zitadel/zitadel/internal/repository/group" + "github.com/zitadel/zitadel/internal/telemetry/tracing" + "github.com/zitadel/zitadel/internal/zerrors" +) + +type CreateGroup struct { + models.ObjectRoot + + Name string + Description string +} + +func (g *CreateGroup) IsValid() error { + if strings.TrimSpace(g.Name) == "" { + return zerrors.ThrowInvalidArgument(nil, "GROUP-m177lN", "Errors.Group.InvalidName") + } + return nil +} + +// CreateGroup creates a new user group in an organization +func (c *Commands) CreateGroup(ctx context.Context, group *CreateGroup) (details *domain.ObjectDetails, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + // todo: check permissions + + if err = group.IsValid(); err != nil { + return nil, err + } + if group.ResourceOwner == "" { + return nil, zerrors.ThrowInvalidArgument(nil, "CMDGRP-msc0Tt", "Errors.Group.MissingOrganizationID") + } + // check whether the organization where the group should be created exists + err = c.checkOrgExists(ctx, group.ResourceOwner) + if err != nil { + return nil, zerrors.ThrowPreconditionFailed(nil, "CMDGRP-j1mH8l", "Errors.Org.NotFound") + } + + // create a unique group ID if not provided + if group.AggregateID == "" { + group.AggregateID, err = c.idGenerator.Next() + if err != nil { + return nil, err + } + } + + // check if a group with the same ID already exists + groupWriteModel, err := c.getGroupWriteModelByID(ctx, group.AggregateID, group.ResourceOwner) + if err != nil { + return nil, err + } + if groupWriteModel.State.Exists() { + return nil, zerrors.ThrowAlreadyExists(nil, "CMDGRP-shRut3", "Errors.Group.AlreadyExists") + } + + err = c.pushAppendAndReduce(ctx, + groupWriteModel, + repo.NewGroupAddedEvent(ctx, + GroupAggregateFromWriteModel(ctx, &groupWriteModel.WriteModel), + group.Name, + group.Description, + )) + if err != nil { + return nil, err + } + return writeModelToObjectDetails(&groupWriteModel.WriteModel), nil +} + +type UpdateGroup struct { + models.ObjectRoot + + Name *string + Description *string +} + +func (g *UpdateGroup) IsValid() error { + if g.Name != nil && strings.TrimSpace(*g.Name) == "" { + return zerrors.ThrowInvalidArgument(nil, "GROUP-dUNd3r", "Errors.Group.InvalidName") + } + return nil +} + +// UpdateGroup updates a user group in an organization +func (c *Commands) UpdateGroup(ctx context.Context, groupUpdate *UpdateGroup) (details *domain.ObjectDetails, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + if err = groupUpdate.IsValid(); err != nil { + return nil, err + } + + // todo: check permissions + existingGroup, err := c.getGroupWriteModelByID(ctx, groupUpdate.AggregateID, groupUpdate.ResourceOwner) + if err != nil { + return nil, err + } + if !existingGroup.State.Exists() { + return nil, zerrors.ThrowNotFound(nil, "CMDGRP-b33zly", "Errors.Group.NotFound") + } + + changedEvent := existingGroup.NewChangedEvent( + ctx, + GroupAggregateFromWriteModel(ctx, &existingGroup.WriteModel), + groupUpdate.Name, + groupUpdate.Description, + ) + if changedEvent == nil { + return writeModelToObjectDetails(&existingGroup.WriteModel), nil + } + + err = c.pushAppendAndReduce(ctx, + existingGroup, + changedEvent) + if err != nil { + return nil, err + } + return writeModelToObjectDetails(&existingGroup.WriteModel), nil +} + +// DeleteGroup deletes a user group from an organization +func (c *Commands) DeleteGroup(ctx context.Context, groupID string) (details *domain.ObjectDetails, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + existingGroup, err := c.getGroupWriteModelByID(ctx, groupID, "") + if err != nil { + return nil, err + } + if !existingGroup.State.Exists() { + return writeModelToObjectDetails(&existingGroup.WriteModel), nil + } + + err = c.pushAppendAndReduce(ctx, + existingGroup, + repo.NewGroupRemovedEvent(ctx, + GroupAggregateFromWriteModel(ctx, &existingGroup.WriteModel), + existingGroup.Name, + )) + if err != nil { + return nil, err + } + return writeModelToObjectDetails(&existingGroup.WriteModel), nil +} + +func (c *Commands) getGroupWriteModelByID(ctx context.Context, id, orgID string) (*GroupWriteModel, error) { + groupWriteModel := NewGroupWriteModel(id, orgID) + err := c.eventstore.FilterToQueryReducer(ctx, groupWriteModel) + if err != nil { + return nil, err + } + return groupWriteModel, nil +} diff --git a/internal/command/group_model.go b/internal/command/group_model.go new file mode 100644 index 000000000..916e78150 --- /dev/null +++ b/internal/command/group_model.go @@ -0,0 +1,87 @@ +package command + +import ( + "context" + + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/group" +) + +// GroupWriteModel represents the write-model for a group. +type GroupWriteModel struct { + eventstore.WriteModel + + Name string + Description string + + State domain.GroupState +} + +// NewGroupWriteModel initializes a new instance of GroupWriteModel from the given Group. +func NewGroupWriteModel(id, orgID string) *GroupWriteModel { + return &GroupWriteModel{ + WriteModel: eventstore.WriteModel{ + AggregateID: id, + ResourceOwner: orgID, + }, + } +} + +// Query constructs a search query for retrieving group-related events based on the GroupWriteModel attributes. +func (g *GroupWriteModel) Query() *eventstore.SearchQueryBuilder { + return eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). + ResourceOwner(g.ResourceOwner). + AddQuery(). + AggregateTypes(group.AggregateType). + AggregateIDs(g.AggregateID). + EventTypes( + group.GroupAddedEventType, + group.GroupChangedEventType, + group.GroupRemovedEventType).Builder() +} + +func (g *GroupWriteModel) Reduce() error { + for _, event := range g.Events { + switch e := event.(type) { + case *group.GroupAddedEvent: + g.AggregateID = e.ID + g.Name = e.Name + g.Description = e.Description + g.State = domain.GroupStateActive + case *group.GroupChangedEvent: + if e.Name != nil { + g.Name = *e.Name + } + if e.Description != nil { + g.Description = *e.Description + } + case *group.GroupRemovedEvent: + g.State = domain.GroupStateRemoved + } + } + return g.WriteModel.Reduce() +} + +func (g *GroupWriteModel) NewChangedEvent(ctx context.Context, agg *eventstore.Aggregate, name, description *string) *group.GroupChangedEvent { + changes := make([]group.GroupChanges, 0) + oldName := "" + + if name != nil && g.Name != *name { + oldName = g.Name + changes = append(changes, group.ChangeName(name)) + } + if description != nil && g.Description != *description { + changes = append(changes, group.ChangeDescription(description)) + } + if len(changes) == 0 { + return nil + } + + return group.NewGroupChangedEvent(ctx, agg, oldName, changes) +} + +// GroupAggregateFromWriteModel maps a WriteModel to a group-specific Aggregate using its type and version. +func GroupAggregateFromWriteModel(ctx context.Context, wm *eventstore.WriteModel) *eventstore.Aggregate { + return eventstore.AggregateFromWriteModelCtx(ctx, wm, group.AggregateType, group.AggregateVersion) +} diff --git a/internal/command/group_test.go b/internal/command/group_test.go new file mode 100644 index 000000000..9b1758465 --- /dev/null +++ b/internal/command/group_test.go @@ -0,0 +1,770 @@ +package command + +import ( + "context" + "errors" + "testing" + + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/require" + + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/eventstore/v1/models" + "github.com/zitadel/zitadel/internal/id" + "github.com/zitadel/zitadel/internal/id/mock" + "github.com/zitadel/zitadel/internal/repository/group" + "github.com/zitadel/zitadel/internal/repository/org" + "github.com/zitadel/zitadel/internal/zerrors" +) + +func TestCommands_CreateGroup(t *testing.T) { + t.Parallel() + + pushErr := errors.New("push error") + filterErr := errors.New("filter error") + idGeneratorErr := errors.New("id generator error") + + type fields struct { + eventstore func(t *testing.T) *eventstore.Eventstore + idGenerator id.Generator + } + type args struct { + ctx context.Context + group *CreateGroup + aggregateID func() (string, error) + } + tests := []struct { + name string + fields fields + args args + want *domain.ObjectDetails + wantErr func(error) bool + }{ + { + name: "invalid group name, error", + fields: fields{ + eventstore: expectEventstore(), + }, + args: args{ + ctx: context.Background(), + group: &CreateGroup{ + ObjectRoot: models.ObjectRoot{ + ResourceOwner: "org1", + }, + Name: " ", + Description: "example group", + }, + }, + wantErr: zerrors.IsErrorInvalidArgument, + }, + { + name: "missing organization id, error", + fields: fields{ + eventstore: expectEventstore(), + }, + args: args{ + ctx: context.Background(), + group: &CreateGroup{ + Name: "example", + Description: "example group", + }, + }, + wantErr: zerrors.IsErrorInvalidArgument, + }, + { + name: "org not found, precondition error", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + ), + }, + args: args{ + ctx: context.Background(), + group: &CreateGroup{ + ObjectRoot: models.ObjectRoot{ + ResourceOwner: "org1", + }, + Name: "example", + Description: "example group", + }, + }, + wantErr: zerrors.IsPreconditionFailed, + }, + { + name: "failed to generate group id, error", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + org.NewOrgAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + "org1", + ), + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + group: &CreateGroup{ + ObjectRoot: models.ObjectRoot{ + ResourceOwner: "org1", + }, + Name: "example", + Description: "example group", + }, + aggregateID: func() (string, error) { + return "", idGeneratorErr + }, + }, + wantErr: func(err error) bool { + return errors.Is(err, idGeneratorErr) + }, + }, + { + name: "group already exists, error", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + org.NewOrgAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + "org1", + ), + ), + ), + expectFilter( + eventFromEventPusher( + group.NewGroupAddedEvent(context.Background(), + &group.NewAggregate("1234", "org1").Aggregate, + "example", + "example group", + ), + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + group: &CreateGroup{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "1234", + ResourceOwner: "org1", + }, + Name: "example", + Description: "example group", + }, + }, + wantErr: zerrors.IsErrorAlreadyExists, + }, + { + name: "failed to get org write model, error", + fields: fields{ + eventstore: expectEventstore( + expectFilterError(filterErr), + ), + }, + args: args{ + ctx: context.Background(), + group: &CreateGroup{ + ObjectRoot: models.ObjectRoot{ + ResourceOwner: "org1", + }, + Name: "example", + Description: "example group", + }, + }, + wantErr: func(err error) bool { + return errors.Is(err, zerrors.ThrowPreconditionFailed(nil, "CMDGRP-j1mH8l", "Errors.Org.NotFound")) + }, + }, + { + name: "failed to get group write model, error", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + org.NewOrgAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + "org1", + ), + ), + ), + expectFilterError(filterErr), + ), + }, + args: args{ + ctx: context.Background(), + group: &CreateGroup{ + ObjectRoot: models.ObjectRoot{ + ResourceOwner: "org1", + }, + Name: "example", + Description: "example group", + }, + aggregateID: func() (string, error) { + return "12345", nil + }, + }, + wantErr: func(err error) bool { + return errors.Is(err, filterErr) + }, + }, + { + name: "failed to push group added event, error", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + org.NewOrgAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + "org1", + ), + ), + ), + expectFilter(), + expectPushFailed( + pushErr, + group.NewGroupAddedEvent(context.Background(), + &group.NewAggregate("12345", "org1").Aggregate, + "example", + "example group", + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + group: &CreateGroup{ + ObjectRoot: models.ObjectRoot{ + ResourceOwner: "org1", + }, + Name: "example", + Description: "example group", + }, + aggregateID: func() (string, error) { + return "12345", nil + }, + }, + wantErr: func(err error) bool { + return errors.Is(err, pushErr) + }, + }, + { + name: "group without user provided id, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + org.NewOrgAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + "org1", + ), + ), + ), + expectFilter(), + expectPush( + group.NewGroupAddedEvent(context.Background(), + &group.NewAggregate("12345", "org1").Aggregate, + "example", + "example group", + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + group: &CreateGroup{ + ObjectRoot: models.ObjectRoot{ + ResourceOwner: "org1", + }, + Name: "example", + Description: "example group", + }, + aggregateID: func() (string, error) { + return "12345", nil + }, // mock value generated by the id generator + }, + want: &domain.ObjectDetails{ + ID: "12345", + ResourceOwner: "org1", + }, + }, + { + name: "group with user provided id, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + org.NewOrgAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + "org1", + ), + ), + ), + expectFilter(), + expectPush( + group.NewGroupAddedEvent(context.Background(), + &group.NewAggregate("9090", "org1").Aggregate, + "example", + "example group", + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + group: &CreateGroup{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "9090", + ResourceOwner: "org1", + }, + Name: "example", + Description: "example group", + }, + }, + want: &domain.ObjectDetails{ + ID: "9090", + ResourceOwner: "org1", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // setup mock id generator + // it's not defined directly in the tests because, + // when run individually, it affects other tests that don't need this mock. + if tt.args.aggregateID != nil { + aggregateID, err := tt.args.aggregateID() + if err != nil { + tt.fields.idGenerator = mock.NewIDGeneratorExpectError(t, err) + } else { + tt.fields.idGenerator = mock.ExpectID(t, aggregateID) + } + } + + c := &Commands{ + eventstore: tt.fields.eventstore(t), + idGenerator: tt.fields.idGenerator, + } + + got, err := c.CreateGroup(tt.args.ctx, tt.args.group) + if tt.wantErr == nil { + require.NoError(t, err) + require.NotEmpty(t, got.ID) + assertObjectDetails(t, tt.want, got) + return + } + require.True(t, tt.wantErr(err)) + }) + } +} + +func TestCommands_UpdateGroup(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 + } + type args struct { + ctx context.Context + group *UpdateGroup + } + tests := []struct { + name string + fields fields + args args + want *domain.ObjectDetails + wantErr func(error) bool + }{ + { + name: "invalid group name, error", + fields: fields{ + eventstore: expectEventstore(), + }, + args: args{ + ctx: context.Background(), + group: &UpdateGroup{ + Name: gu.Ptr(" "), + Description: gu.Ptr("example group"), + }, + }, + wantErr: zerrors.IsErrorInvalidArgument, + }, + { + name: "group not found, error", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + ), + }, + args: args{ + ctx: context.Background(), + group: &UpdateGroup{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "1234", + }, + Name: gu.Ptr("updated name"), + Description: gu.Ptr("updated description"), + }, + }, + wantErr: zerrors.IsNotFound, + }, + { + name: "failed to get group write model, error", + fields: fields{ + eventstore: expectEventstore( + expectFilterError(filterErr), + ), + }, + args: args{ + ctx: context.Background(), + group: &UpdateGroup{ + Name: gu.Ptr("updated group name"), + Description: gu.Ptr("updated group description"), + }, + }, + wantErr: func(err error) bool { + return errors.Is(err, filterErr) + }, + }, + { + name: "failed to push group changed event, error", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + group.NewGroupAddedEvent(context.Background(), + &group.NewAggregate("1234", "org1").Aggregate, + "group1", + "group1 description", + ), + ), + ), + expectPushFailed( + pushErr, + group.NewGroupChangedEvent(context.Background(), + &group.NewAggregate("1234", "org1").Aggregate, + "group1", + []group.GroupChanges{ + group.ChangeName(gu.Ptr("updated group name")), + group.ChangeDescription(gu.Ptr("updated group description")), + }, + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + group: &UpdateGroup{ + Name: gu.Ptr("updated group name"), + Description: gu.Ptr("updated group description"), + }, + }, + wantErr: func(err error) bool { + return errors.Is(err, pushErr) + }, + }, + { + name: "no change, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + group.NewGroupAddedEvent(context.Background(), + &group.NewAggregate("1234", "org1").Aggregate, + "group1", + "group1 description", + ), + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + group: &UpdateGroup{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "1234", + }, + Name: gu.Ptr("group1"), + Description: gu.Ptr("group1 description"), + }, + }, + want: &domain.ObjectDetails{ + ID: "1234", + ResourceOwner: "org1", + }, + }, + { + name: "update group name, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + group.NewGroupAddedEvent(context.Background(), + &group.NewAggregate("1234", "org1").Aggregate, + "group1", + "group1 description", + ), + ), + ), + expectPush( + group.NewGroupChangedEvent(context.Background(), + &group.NewAggregate("1234", "org1").Aggregate, + "group1", + []group.GroupChanges{ + group.ChangeName(gu.Ptr("groupXX")), + }, + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + group: &UpdateGroup{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "1234", + }, + Name: gu.Ptr("groupXX"), + }, + }, + want: &domain.ObjectDetails{ + ID: "1234", + ResourceOwner: "org1", + }, + }, + { + name: "update group description, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + group.NewGroupAddedEvent(context.Background(), + &group.NewAggregate("1234", "org1").Aggregate, + "group1", + "group1 description", + ), + ), + ), + expectPush( + group.NewGroupChangedEvent(context.Background(), + &group.NewAggregate("1234", "org1").Aggregate, + "group1", + []group.GroupChanges{ + group.ChangeDescription(gu.Ptr("updated group description")), + }, + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + group: &UpdateGroup{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "1234", + }, + Description: gu.Ptr("updated group description"), + }, + }, + want: &domain.ObjectDetails{ + ID: "1234", + ResourceOwner: "org1", + }, + }, + { + name: "full update (group name and description), ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + group.NewGroupAddedEvent(context.Background(), + &group.NewAggregate("1234", "org1").Aggregate, + "group1", + "group1 description", + ), + ), + ), + expectPush( + group.NewGroupChangedEvent(context.Background(), + &group.NewAggregate("1234", "org1").Aggregate, + "group1", + []group.GroupChanges{ + group.ChangeName(gu.Ptr("groupXX")), + group.ChangeDescription(gu.Ptr("updated group description")), + }, + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + group: &UpdateGroup{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "1234", + }, + Name: gu.Ptr("groupXX"), + Description: gu.Ptr("updated group description"), + }, + }, + want: &domain.ObjectDetails{ + ID: "1234", + ResourceOwner: "org1", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + c := &Commands{ + eventstore: tt.fields.eventstore(t), + } + got, err := c.UpdateGroup(tt.args.ctx, tt.args.group) + if tt.wantErr == nil { + require.NoError(t, err) + require.NotEmpty(t, got.ID) + assertObjectDetails(t, tt.want, got) + return + } + require.True(t, tt.wantErr(err)) + }) + } +} + +func TestCommands_DeleteGroup(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 + } + type args struct { + ctx context.Context + groupID 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{ + ctx: context.Background(), + groupID: "1234", + }, + wantErr: func(err error) bool { + return errors.Is(err, filterErr) + }, + }, + { + name: "group not found, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + ), + }, + args: args{ + ctx: context.Background(), + groupID: "1234", + }, + want: &domain.ObjectDetails{ + ID: "1234", + }, + }, + { + name: "failed to push group delete event, error", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + group.NewGroupAddedEvent(context.Background(), + &group.NewAggregate("1234", "").Aggregate, + "group1", + "group1 description", + ), + ), + ), + expectPushFailed( + pushErr, + group.NewGroupRemovedEvent(context.Background(), + &group.NewAggregate("1234", "").Aggregate, + "group1", + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + groupID: "1234", + }, + wantErr: func(err error) bool { + return errors.Is(err, pushErr) + }, + }, + { + name: "delete group, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + group.NewGroupAddedEvent(context.Background(), + &group.NewAggregate("1234", "").Aggregate, + "group1", + "group1 description", + ), + ), + ), + expectPush( + eventFromEventPusher( + group.NewGroupRemovedEvent(context.Background(), + &group.NewAggregate("1234", "").Aggregate, + "group1", + ), + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + groupID: "1234", + }, + want: &domain.ObjectDetails{ + ID: "1234", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + c := &Commands{ + eventstore: tt.fields.eventstore(t), + } + got, err := c.DeleteGroup(tt.args.ctx, tt.args.groupID) + if tt.wantErr == nil { + require.NoError(t, err) + require.NotEmpty(t, got.ID) + assertObjectDetails(t, tt.want, got) + return + } + require.True(t, tt.wantErr(err)) + }) + } +} diff --git a/internal/domain/group.go b/internal/domain/group.go new file mode 100644 index 000000000..a23e2b58d --- /dev/null +++ b/internal/domain/group.go @@ -0,0 +1,40 @@ +package domain + +import ( + "strings" + + "github.com/zitadel/zitadel/internal/eventstore/v1/models" + "github.com/zitadel/zitadel/internal/zerrors" +) + +const ( + maxGroupNameLen = 200 +) + +// Group represents a user group in an organization +type Group struct { + models.ObjectRoot + + Name string + Description string +} + +func (g *Group) IsValid() error { + groupName := strings.TrimSpace(g.Name) + if groupName == "" || len(groupName) > maxGroupNameLen { + return zerrors.ThrowInvalidArgument(nil, "GROUP-m177lN", "Errors.Group.InvalidName") + } + return nil +} + +type GroupState int32 + +const ( + GroupStateUnspecified GroupState = iota + GroupStateActive + GroupStateRemoved +) + +func (g GroupState) Exists() bool { + return g != GroupStateRemoved && g != GroupStateUnspecified +} diff --git a/internal/integration/client.go b/internal/integration/client.go index fae837c8e..d6dc45a9d 100644 --- a/internal/integration/client.go +++ b/internal/integration/client.go @@ -29,6 +29,7 @@ import ( authorization "github.com/zitadel/zitadel/pkg/grpc/authorization/v2beta" "github.com/zitadel/zitadel/pkg/grpc/feature/v2" feature_v2beta "github.com/zitadel/zitadel/pkg/grpc/feature/v2beta" + group_v2 "github.com/zitadel/zitadel/pkg/grpc/group/v2" "github.com/zitadel/zitadel/pkg/grpc/idp" idp_pb "github.com/zitadel/zitadel/pkg/grpc/idp/v2" instance "github.com/zitadel/zitadel/pkg/grpc/instance/v2beta" @@ -86,6 +87,7 @@ type Client struct { AppV2Beta app.AppServiceClient InternalPermissionv2Beta internal_permission_v2beta.InternalPermissionServiceClient AuthorizationV2Beta authorization.AuthorizationServiceClient + GroupV2 group_v2.GroupServiceClient } func NewDefaultClient(ctx context.Context) (*Client, error) { @@ -130,6 +132,7 @@ func newClient(ctx context.Context, target string) (*Client, error) { AppV2Beta: app.NewAppServiceClient(cc), InternalPermissionv2Beta: internal_permission_v2beta.NewInternalPermissionServiceClient(cc), AuthorizationV2Beta: authorization.NewAuthorizationServiceClient(cc), + GroupV2: group_v2.NewGroupServiceClient(cc), } return client, client.pollHealth(ctx) } @@ -1285,3 +1288,20 @@ func (i *Instance) ActivateSchemaUser(ctx context.Context, orgID string, userID logging.OnError(err).Fatal("reactivate user") return user } + +func (i *Instance) CreateGroup(ctx context.Context, t *testing.T, orgID, name string) *group_v2.CreateGroupResponse { + resp, err := i.Client.GroupV2.CreateGroup(ctx, &group_v2.CreateGroupRequest{ + OrganizationId: orgID, + Name: name, + }) + require.NoError(t, err) + return resp +} + +func (i *Instance) DeleteGroup(ctx context.Context, t *testing.T, id string) *group_v2.DeleteGroupResponse { + resp, err := i.Client.GroupV2.DeleteGroup(ctx, &group_v2.DeleteGroupRequest{ + Id: id, + }) + require.NoError(t, err) + return resp +} diff --git a/internal/integration/name.go b/internal/integration/name.go index 49f30ceeb..e441d1ad2 100644 --- a/internal/integration/name.go +++ b/internal/integration/name.go @@ -91,3 +91,7 @@ func Slogan() string { func Number() int { return gofakeit.Number(0, 1_000_000) } + +func GroupName() string { + return "group_" + appName() +} diff --git a/internal/query/group.go b/internal/query/group.go new file mode 100644 index 000000000..983c9f60f --- /dev/null +++ b/internal/query/group.go @@ -0,0 +1,27 @@ +package query + +import ( + "context" + "time" + + "github.com/zitadel/zitadel/internal/zerrors" +) + +type Groups struct { + SearchResponse + Groups []*Group +} + +type Group struct { + ID string + Name string + Description string + CreationDate time.Time + ChangeDate time.Time + ResourceOwner string +} + +// SearchGroups returns the list of groups that match the search criteria +func (q *Queries) SearchGroups(ctx context.Context) (*Groups, error) { + return nil, zerrors.ThrowUnimplemented(nil, "QUERY-grpfli", "Not implemented") +} diff --git a/internal/repository/group/aggregate.go b/internal/repository/group/aggregate.go new file mode 100644 index 000000000..a1de9de0a --- /dev/null +++ b/internal/repository/group/aggregate.go @@ -0,0 +1,27 @@ +package group + +import ( + "github.com/zitadel/zitadel/internal/eventstore" +) + +const ( + groupEventTypePrefix = eventstore.EventType("group.") + + AggregateType = "group" + AggregateVersion = "v1" +) + +type Aggregate struct { + eventstore.Aggregate +} + +func NewAggregate(id, resourceOwner string) *Aggregate { + return &Aggregate{ + Aggregate: eventstore.Aggregate{ + Type: AggregateType, + Version: AggregateVersion, + ID: id, + ResourceOwner: resourceOwner, + }, + } +} diff --git a/internal/repository/group/eventstore.go b/internal/repository/group/eventstore.go new file mode 100644 index 000000000..60e3f92ca --- /dev/null +++ b/internal/repository/group/eventstore.go @@ -0,0 +1,9 @@ +package group + +import "github.com/zitadel/zitadel/internal/eventstore" + +func init() { + eventstore.RegisterFilterEventMapper(AggregateType, GroupAddedEventType, eventstore.GenericEventMapper[GroupAddedEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, GroupChangedEventType, eventstore.GenericEventMapper[GroupChangedEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, GroupRemovedEventType, eventstore.GenericEventMapper[GroupRemovedEvent]) +} diff --git a/internal/repository/group/group.go b/internal/repository/group/group.go new file mode 100644 index 000000000..558cb8024 --- /dev/null +++ b/internal/repository/group/group.go @@ -0,0 +1,155 @@ +package group + +import ( + "context" + + "github.com/zitadel/zitadel/internal/eventstore" +) + +const ( + uniqueGroupname = "group_name" + GroupAddedEventType = groupEventTypePrefix + "added" + GroupChangedEventType = groupEventTypePrefix + "changed" + GroupRemovedEventType = groupEventTypePrefix + "removed" +) + +type GroupAddedEvent struct { + eventstore.BaseEvent `json:"-"` + + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` +} + +func NewGroupAddedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, + name, + description string, +) *GroupAddedEvent { + return &GroupAddedEvent{ + BaseEvent: *eventstore.NewBaseEventForPush( + ctx, + aggregate, + GroupAddedEventType), + Name: name, + Description: description, + } +} + +func (g *GroupAddedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + g.BaseEvent = *event +} + +func (g *GroupAddedEvent) Payload() any { + return g +} + +func (g *GroupAddedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + return []*eventstore.UniqueConstraint{NewAddGroupNameUniqueConstraint(g.Name, g.Aggregate().ResourceOwner)} +} + +func NewAddGroupNameUniqueConstraint(groupName, organizationID string) *eventstore.UniqueConstraint { + return eventstore.NewAddEventUniqueConstraint( + uniqueGroupname, + groupName+":"+organizationID, + "Errors.Group.AlreadyExists") +} + +func NewRemoveGroupNameUniqueConstraint(groupName, organizationID string) *eventstore.UniqueConstraint { + return eventstore.NewRemoveUniqueConstraint( + uniqueGroupname, + groupName+":"+organizationID, + ) +} + +type GroupChangedEvent struct { + eventstore.BaseEvent `json:"-"` + + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` + + oldName string +} + +func NewGroupChangedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, + oldName string, + changes []GroupChanges, +) *GroupChangedEvent { + changeEvent := &GroupChangedEvent{ + BaseEvent: *eventstore.NewBaseEventForPush( + ctx, + aggregate, + GroupChangedEventType, + ), + oldName: oldName, + } + for _, change := range changes { + change(changeEvent) + } + return changeEvent +} + +type GroupChanges func(event *GroupChangedEvent) + +func ChangeName(name *string) func(event *GroupChangedEvent) { + return func(event *GroupChangedEvent) { + event.Name = name + } +} + +func ChangeDescription(description *string) func(event *GroupChangedEvent) { + return func(event *GroupChangedEvent) { + event.Description = description + } +} + +func (g *GroupChangedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + g.BaseEvent = *event +} + +func (g *GroupChangedEvent) Payload() any { + return g +} + +func (g *GroupChangedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + if g.Name == nil { + return nil + } + return []*eventstore.UniqueConstraint{ + NewRemoveGroupNameUniqueConstraint(g.oldName, g.Aggregate().ResourceOwner), + NewAddGroupNameUniqueConstraint(*g.Name, g.Aggregate().ResourceOwner)} +} + +type GroupRemovedEvent struct { + eventstore.BaseEvent `json:"-"` + + ID string `json:"id,omitempty"` + + name string +} + +func NewGroupRemovedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, + name string, +) *GroupRemovedEvent { + return &GroupRemovedEvent{ + BaseEvent: *eventstore.NewBaseEventForPush(ctx, aggregate, GroupRemovedEventType), + ID: aggregate.ID, + name: name, + } +} + +func (g *GroupRemovedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + g.BaseEvent = *event +} + +func (g *GroupRemovedEvent) Payload() any { + return g +} + +func (g *GroupRemovedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + return []*eventstore.UniqueConstraint{NewRemoveGroupNameUniqueConstraint(g.name, g.Aggregate().ResourceOwner)} +} diff --git a/proto/zitadel/group/v2/group.proto b/proto/zitadel/group/v2/group.proto new file mode 100644 index 000000000..90b9df6d3 --- /dev/null +++ b/proto/zitadel/group/v2/group.proto @@ -0,0 +1,97 @@ +syntax = "proto3"; + +import "google/protobuf/timestamp.proto"; +import "protoc-gen-openapiv2/options/annotations.proto"; +import "validate/validate.proto"; + +import "zitadel/filter/v2/filter.proto"; + +package zitadel.group.v2; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/group/v2;group"; + +message Group { + // Unique identifier of the user group. + string id = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"69629023906488334\"" + } + ]; + + // Unique name of the user group inside the corresponding organization. + string name = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"Support Group\""; + } + ]; + + // Description the user group. + string description = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"Support Group\""; + } + ]; + + // The ID of the organization to which the group belongs. + string organization_id = 4 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"69629098906488334\"" + } + ]; + + // Timestamp when the group was last changed. + google.protobuf.Timestamp change_date = 5 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; + + // Timestamp when the user group was created + google.protobuf.Timestamp creation_date = 6 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; +} + +message GroupsSearchFilter { + oneof filter { + option (validate.required) = true; + + // Search for groups by their IDs. + zitadel.filter.v2.InIDsFilter group_ids = 1; + // Search for groups by their names. + GroupNameFilter name_filter = 2; + // Search for groups by their organization ID. + zitadel.filter.v2.IDFilter organization_id = 3; + } +} + +message GroupNameFilter { + // Name of the user group. + string name = 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: "\"My Group\""; + } + ]; + + // Defines which text comparison method used for the name query. + zitadel.filter.v2.TextFilterMethod method = 2 [ + (validate.rules).enum.defined_only = true, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "defines which text equality method is used"; + } + ]; +} + + +enum FieldName { + FIELD_NAME_UNSPECIFIED = 0; + FIELD_NAME_ID = 1; + FIELD_NAME_NAME = 2; + FIELD_NAME_CREATION_DATE = 3; + FIELD_NAME_CHANGE_DATE = 4; +} \ No newline at end of file diff --git a/proto/zitadel/group/v2/group_service.proto b/proto/zitadel/group/v2/group_service.proto new file mode 100644 index 000000000..5cf9c9108 --- /dev/null +++ b/proto/zitadel/group/v2/group_service.proto @@ -0,0 +1,467 @@ +syntax = "proto3"; + +package zitadel.group.v2; + +import "google/api/annotations.proto"; +import "google/api/field_behavior.proto"; +import "google/protobuf/struct.proto"; +import "google/protobuf/timestamp.proto"; +import "protoc-gen-openapiv2/options/annotations.proto"; +import "validate/validate.proto"; + +import "zitadel/protoc_gen_zitadel/v2/options.proto"; + +import "zitadel/filter/v2/filter.proto"; +import "zitadel/group/v2/group.proto"; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/group/v2;group"; + +option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = { + info: { + title: "Group Service"; + version: "2.0"; + description: "This API is intended to manage user groups in ZITADEL."; + contact:{ + name: "ZITADEL" + url: "https://zitadel.com" + email: "hi@zitadel.com" + } + license: { + name: "Apache 2.0", + url: "https://github.com/zitadel/zitadel/blob/main/LICENSING.md"; + }; + }; + schemes: HTTPS; + schemes: HTTP; + + consumes: "application/json"; + consumes: "application/grpc"; + + produces: "application/json"; + produces: "application/grpc"; + + consumes: "application/grpc-web+proto"; + produces: "application/grpc-web+proto"; + + host: "$CUSTOM-DOMAIN"; + base_path: "/"; + + external_docs: { + description: "Detailed information about ZITADEL", + url: "https://zitadel.com/docs" + } + security_definitions: { + security: { + key: "OAuth2"; + value: { + type: TYPE_OAUTH2; + flow: FLOW_ACCESS_CODE; + authorization_url: "$CUSTOM-DOMAIN/oauth/v2/authorize"; + token_url: "$CUSTOM-DOMAIN/oauth/v2/token"; + scopes: { + scope: { + key: "openid"; + value: "openid"; + } + scope: { + key: "urn:zitadel:iam:org:project:id:zitadel:aud"; + value: "urn:zitadel:iam:org:project:id:zitadel:aud"; + } + } + } + } + } + security: { + security_requirement: { + key: "OAuth2"; + value: { + scope: "openid"; + scope: "urn:zitadel:iam:org:project:id:zitadel:aud"; + } + } + } + responses: { + key: "403"; + value: { + description: "Returned when the user does not have permission to access the resource."; + schema: { + json_schema: { + ref: "#/definitions/rpcStatus"; + } + } + } + } + responses: { + key: "404"; + value: { + description: "Returned when the resource does not exist."; + schema: { + json_schema: { + ref: "#/definitions/rpcStatus"; + } + } + } + } +}; + +// GroupService is used to manage user groups. +// This service provides methods to create, retrieve, update, and delete user groups in an organization. +service GroupService { + + // CreateGroup + // + // CreateGroup creates a new user group in an organization. + // + // Required permissions: // TODO + // - "iam.member.write" for instance administrators + // - "org.member.write" for organization administrators + rpc CreateGroup(CreateGroupRequest) returns (CreateGroupResponse) { + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + http_response: { + success_code: 201 + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "201"; + value: { + description: "The user group was created successfully"; + }; + }; + responses: { + key: "400"; + value: { + description: "Invalid request to create user group"; + schema: { + json_schema: { + ref: "#/definitions/rpcStatus"; + }; + }; + }; + }; + responses: { + key: "409" + value: { + description: "The user group already exists."; + schema: { + json_schema: { + ref: "#/definitions/rpcStatus"; + }; + }; + } + }; + }; + } + + // GetGroup + // + // Retrieves a group based on its ID. + // + // Required permission: + // - TODO + rpc GetGroup(GetGroupRequest) returns (GetGroupResponse) { + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200" + value: { + description: "Group retrieved successfully"; + } + }; + responses: { + key: "404" + value: { + description: "The requested group does not exist."; + schema: { + json_schema: { + ref: "#/definitions/rpcStatus"; + }; + }; + } + }; + }; + } + + // ListGroups + // + // ListGroups returns all groups matching the request and necessary permissions from an organization. + // + // Required permissions: // TODO + // - "iam.member.read" for instance administrators + // - "org.member.read" for organization administrators + rpc ListGroups(ListGroupsRequest) returns (ListGroupsResponse) { + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "A list of all groups matching the query"; + }; + }; + responses: { + key: "400"; + value: { + description: "invalid list group query"; + schema: { + json_schema: { + ref: "#/definitions/rpcStatus"; + }; + }; + }; + }; + }; + } + + // UpdateGroup + // + // UpdateGroup updates the user group. + // + // In case the group there aren't any changes, the request will return a successful response as + // the desired state is already achieved. + // You can check the change date in the response to verify if the group was updated by the request. + // + // Required permissions: // TODO + // - "iam.member.write" for instance administrators + // - "org.member.write" for organization administrators + rpc UpdateGroup(UpdateGroupRequest) returns (UpdateGroupResponse) { + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "The user group was updated successfully"; + }; + }; + responses: { + key: "404"; + value: { + description: "The user group or one of the roles do not exist."; + schema: { + json_schema: { + ref: "#/definitions/rpcStatus"; + }; + }; + } + } + }; + } + + // DeleteGroup + // + // DeleteGroup deletes the group. + // + // In case the group is not found, the request will return a successful response as + // the desired state is already achieved. + // You can check the deletion date in the response to verify if the group was deleted by the request. + // + // Required permissions: // TODO + // - "iam.member.delete" for instance administrators + // - "org.member.delete" for organization administrators + rpc DeleteGroup(DeleteGroupRequest) returns (DeleteGroupResponse) { + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "The user group was deleted successfully."; + }; + }; + responses: { + key: "404"; + value: { + description: "The user group not found."; + schema: { + json_schema: { + ref: "#/definitions/rpcStatus"; + }; + }; + }; + }; + }; + } +} + +message CreateGroupRequest { + // The ID of the Organization where the group should be created. + string organization_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: "\"163840776835432345\""; + }, + (google.api.field_behavior) = REQUIRED + ]; + + // Name of the group. This must be unique inside the corresponding organization. + string name = 2 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"My group\""; + } + ]; + + // Optionally, provide a description of the group that could help others identify its purpose. + string description = 3 [ + (validate.rules).string = {max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + max_length: 200; + example: "\"This is an example group\""; + } + ]; + + // In case you need to specify the unique identifier of the group, such as to recreate a previously existing group, you can provide the ID. + // If omitted, the system will generate an ID for you. + // This is the preferred way. The generated ID will be returned in the response. + optional string id = 4 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"69629026806489455\""; + } + ]; +} + +message CreateGroupResponse { + // ID is the unique identifier of the newly created user group. + string id = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "\"This is the ID of the newly created user group\""; + example: "\"69629012906488334\""; + } + ]; + + // CreationDate is the timestamp when the user group was created. + google.protobuf.Timestamp creation_date = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-08-11T15:00:00.051Z\""; + } + ]; +} + +message ListGroupsRequest { + // Criteria to list the user groups. + repeated GroupsSearchFilter filters = 1; + + // Pagination and sorting. + zitadel.filter.v2.PaginationRequest pagination = 2; + + // The field the result is sorted by. + optional FieldName sorting_column = 3; +} + +message ListGroupsResponse { + // The list of groups. + repeated Group groups = 1; + + // Contains the total number of groups matching the query and the applied limit. + zitadel.filter.v2.PaginationResponse pagination = 2; +} + +message GetGroupRequest { + // The ID of the group to be retrieved. + 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: "\"69629026806489455\""; + }, + (google.api.field_behavior) = REQUIRED + ]; +} + +message GetGroupResponse { + Group group = 1; +} + +message UpdateGroupRequest { + // The ID of the user group to be updated. + string id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"69629012906488334\""; + } + ]; + + // Name of the group. The name must be unique inside the corresponding organization. + // If omitted, the name will be unchanged. + optional string name = 2 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"My updated group\""; + } + ]; + + // Optionally, provide a new description of the group that could help others identify its purpose. + // If omitted, the description will be unchanged. + optional string description = 3 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"This is an example group\""; + } + ]; +} + +message UpdateGroupResponse { + google.protobuf.Timestamp change_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-08-11T15:00:00.051Z\""; + } + ]; +} + +message DeleteGroupRequest { + // ID of the user group to be deleted. + 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 + ]; +} + +message DeleteGroupResponse { + google.protobuf.Timestamp deletion_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-08-11T15:00:00.051Z\""; + } + ]; +} \ No newline at end of file