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
+4
View File
@@ -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)
+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
}
+160
View File
@@ -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
}
+87
View File
@@ -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)
}
+770
View File
@@ -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))
})
}
}
+40
View File
@@ -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
}
+20
View File
@@ -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
}
+4
View File
@@ -91,3 +91,7 @@ func Slogan() string {
func Number() int {
return gofakeit.Number(0, 1_000_000)
}
func GroupName() string {
return "group_" + appName()
}
+27
View File
@@ -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")
}
+27
View File
@@ -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,
},
}
}
+9
View File
@@ -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])
}
+155
View File
@@ -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)}
}
+97
View File
@@ -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;
}
+467
View File
@@ -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: "[email protected]"
}
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\"";
}
];
}