mirror of
https://github.com/andwati/zitadel.git
synced 2026-10-24 16:22:16 +00:00
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:
co-authored by
Livio Spring
parent
ee3654fe6f
commit
b81dedcaea
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}())
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -91,3 +91,7 @@ func Slogan() string {
|
||||
func Number() int {
|
||||
return gofakeit.Number(0, 1_000_000)
|
||||
}
|
||||
|
||||
func GroupName() string {
|
||||
return "group_" + appName()
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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)}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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\"";
|
||||
}
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user