diff --git a/cmd/setup/03.go b/cmd/setup/03.go index 867e9d4981..24b0942260 100644 --- a/cmd/setup/03.go +++ b/cmd/setup/03.go @@ -21,7 +21,7 @@ import ( type FirstInstance struct { InstanceName string DefaultLanguage language.Tag - Org command.OrgSetup + Org command.InstanceOrgSetup MachineKeyPath string PatPath string diff --git a/cmd/start/start.go b/cmd/start/start.go index 49cd981e5f..d0df178c94 100644 --- a/cmd/start/start.go +++ b/cmd/start/start.go @@ -36,6 +36,7 @@ import ( "github.com/zitadel/zitadel/internal/api/grpc/auth" "github.com/zitadel/zitadel/internal/api/grpc/management" oidc_v2 "github.com/zitadel/zitadel/internal/api/grpc/oidc/v2" + "github.com/zitadel/zitadel/internal/api/grpc/org/v2" "github.com/zitadel/zitadel/internal/api/grpc/session/v2" "github.com/zitadel/zitadel/internal/api/grpc/settings/v2" "github.com/zitadel/zitadel/internal/api/grpc/system" @@ -351,6 +352,9 @@ func startAPIs( if err := apis.RegisterService(ctx, settings.CreateServer(commands, queries, config.ExternalSecure)); err != nil { return err } + if err := apis.RegisterService(ctx, org.CreateServer(commands, queries, permissionCheck)); err != nil { + return err + } instanceInterceptor := middleware.InstanceInterceptor(queries, config.HTTP1HostHeader, login.IgnoreInstanceEndpoints...) assetsCache := middleware.AssetsCacheInterceptor(config.AssetStorage.Cache.MaxAge, config.AssetStorage.Cache.SharedMaxAge) apis.RegisterHandlerOnPrefix(assets.HandlerPrefix, assets.NewHandler(commands, verifier, config.InternalAuthZ, id.SonyFlakeGenerator(), store, queries, middleware.CallDurationHandler, instanceInterceptor.Handler, assetsCache.Handler, limitingAccessInterceptor.Handle)) diff --git a/internal/api/grpc/admin/org.go b/internal/api/grpc/admin/org.go index 4874b01711..9edd6f123c 100644 --- a/internal/api/grpc/admin/org.go +++ b/internal/api/grpc/admin/org.go @@ -72,18 +72,26 @@ func (s *Server) SetUpOrg(ctx context.Context, req *admin_pb.SetUpOrgRequest) (* } human := setUpOrgHumanToCommand(req.User.(*admin_pb.SetUpOrgRequest_Human_).Human) //TODO: handle machine - userID, objectDetails, err := s.command.SetUpOrg(ctx, &command.OrgSetup{ + createdOrg, err := s.command.SetUpOrg(ctx, &command.OrgSetup{ Name: req.Org.Name, CustomDomain: req.Org.Domain, - Human: human, - Roles: req.Roles, - }, userIDs...) + Admins: []*command.OrgSetupAdmin{ + { + Human: human, + Roles: req.Roles, + }, + }, + }, true, userIDs...) if err != nil { return nil, err } + var userID string + if len(createdOrg.CreatedAdmins) == 1 { + userID = createdOrg.CreatedAdmins[0].ID + } return &admin_pb.SetUpOrgResponse{ - Details: object.DomainToAddDetailsPb(objectDetails), - OrgId: objectDetails.ResourceOwner, + Details: object.DomainToAddDetailsPb(createdOrg.ObjectDetails), + OrgId: createdOrg.ObjectDetails.ResourceOwner, UserId: userID, }, nil } diff --git a/internal/api/grpc/org/v2/org.go b/internal/api/grpc/org/v2/org.go new file mode 100644 index 0000000000..cc8f78cdd6 --- /dev/null +++ b/internal/api/grpc/org/v2/org.go @@ -0,0 +1,83 @@ +package org + +import ( + "context" + + "github.com/zitadel/zitadel/internal/api/grpc/object/v2" + "github.com/zitadel/zitadel/internal/api/grpc/user/v2" + "github.com/zitadel/zitadel/internal/command" + caos_errs "github.com/zitadel/zitadel/internal/errors" + org "github.com/zitadel/zitadel/pkg/grpc/org/v2beta" +) + +func (s *Server) AddOrganization(ctx context.Context, request *org.AddOrganizationRequest) (*org.AddOrganizationResponse, error) { + orgSetup, err := addOrganizationRequestToCommand(request) + if err != nil { + return nil, err + } + createdOrg, err := s.command.SetUpOrg(ctx, orgSetup, false) + if err != nil { + return nil, err + } + return createdOrganizationToPb(createdOrg) +} + +func addOrganizationRequestToCommand(request *org.AddOrganizationRequest) (*command.OrgSetup, error) { + admins, err := addOrganizationRequestAdminsToCommand(request.GetAdmins()) + if err != nil { + return nil, err + } + return &command.OrgSetup{ + Name: request.GetName(), + CustomDomain: "", + Admins: admins, + }, nil +} + +func addOrganizationRequestAdminsToCommand(requestAdmins []*org.AddOrganizationRequest_Admin) (admins []*command.OrgSetupAdmin, err error) { + admins = make([]*command.OrgSetupAdmin, len(requestAdmins)) + for i, admin := range requestAdmins { + admins[i], err = addOrganizationRequestAdminToCommand(admin) + if err != nil { + return nil, err + } + } + return admins, nil +} + +func addOrganizationRequestAdminToCommand(admin *org.AddOrganizationRequest_Admin) (*command.OrgSetupAdmin, error) { + switch a := admin.GetUserType().(type) { + case *org.AddOrganizationRequest_Admin_UserId: + return &command.OrgSetupAdmin{ + ID: a.UserId, + Roles: admin.GetRoles(), + }, nil + case *org.AddOrganizationRequest_Admin_Human: + human, err := user.AddUserRequestToAddHuman(a.Human) + if err != nil { + return nil, err + } + return &command.OrgSetupAdmin{ + Human: human, + Roles: admin.GetRoles(), + }, nil + default: + return nil, caos_errs.ThrowUnimplementedf(nil, "ORGv2-SD2r1", "userType oneOf %T in method AddOrganization not implemented", a) + } +} + +func createdOrganizationToPb(createdOrg *command.CreatedOrg) (_ *org.AddOrganizationResponse, err error) { + admins := make([]*org.AddOrganizationResponse_CreatedAdmin, len(createdOrg.CreatedAdmins)) + for i, admin := range createdOrg.CreatedAdmins { + admins[i] = &org.AddOrganizationResponse_CreatedAdmin{ + UserId: admin.ID, + EmailCode: admin.EmailCode, + PhoneCode: admin.PhoneCode, + } + } + return &org.AddOrganizationResponse{ + Details: object.DomainToDetailsPb(createdOrg.ObjectDetails), + OrganizationId: createdOrg.ObjectDetails.ResourceOwner, + CreatedAdmins: admins, + }, nil +} diff --git a/internal/api/grpc/org/v2/org_integration_test.go b/internal/api/grpc/org/v2/org_integration_test.go new file mode 100644 index 0000000000..570b0afa13 --- /dev/null +++ b/internal/api/grpc/org/v2/org_integration_test.go @@ -0,0 +1,206 @@ +//go:build integration + +package org_test + +import ( + "context" + "fmt" + "os" + "testing" + "time" + + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zitadel/zitadel/internal/integration" + org "github.com/zitadel/zitadel/pkg/grpc/org/v2beta" + user "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha" +) + +var ( + CTX context.Context + Tester *integration.Tester + Client org.OrganizationServiceClient + User *user.AddHumanUserResponse +) + +func TestMain(m *testing.M) { + os.Exit(func() int { + ctx, errCtx, cancel := integration.Contexts(5 * time.Minute) + defer cancel() + + Tester = integration.NewTester(ctx) + defer Tester.Done() + Client = Tester.Client.OrgV2 + + CTX, _ = Tester.WithAuthorization(ctx, integration.IAMOwner), errCtx + User = Tester.CreateHumanUser(CTX) + return m.Run() + }()) +} + +func TestServer_AddOrganization(t *testing.T) { + idpID := Tester.AddGenericOAuthProvider(t) + + tests := []struct { + name string + ctx context.Context + req *org.AddOrganizationRequest + want *org.AddOrganizationResponse + wantErr bool + }{ + { + name: "missing permission", + ctx: Tester.WithAuthorization(context.Background(), integration.OrgOwner), + req: &org.AddOrganizationRequest{ + Name: "name", + Admins: nil, + }, + wantErr: true, + }, + { + name: "empty name", + ctx: CTX, + req: &org.AddOrganizationRequest{ + Name: "", + Admins: nil, + }, + wantErr: true, + }, + { + name: "invalid admin type", + ctx: CTX, + req: &org.AddOrganizationRequest{ + Name: fmt.Sprintf("%d", time.Now().UnixNano()), + Admins: []*org.AddOrganizationRequest_Admin{ + {}, + }, + }, + wantErr: true, + }, + { + name: "admin with init", + ctx: CTX, + req: &org.AddOrganizationRequest{ + Name: fmt.Sprintf("%d", time.Now().UnixNano()), + Admins: []*org.AddOrganizationRequest_Admin{ + { + UserType: &org.AddOrganizationRequest_Admin_Human{ + Human: &user.AddHumanUserRequest{ + Profile: &user.SetHumanProfile{ + FirstName: "firstname", + LastName: "lastname", + }, + Email: &user.SetHumanEmail{ + Email: fmt.Sprintf("%d@mouse.com", time.Now().UnixNano()), + Verification: &user.SetHumanEmail_ReturnCode{ + ReturnCode: &user.ReturnEmailVerificationCode{}, + }, + }, + }, + }, + }, + }, + }, + want: &org.AddOrganizationResponse{ + OrganizationId: integration.NotEmpty, + CreatedAdmins: []*org.AddOrganizationResponse_CreatedAdmin{ + { + UserId: integration.NotEmpty, + EmailCode: gu.Ptr(integration.NotEmpty), + PhoneCode: nil, + }, + }, + }, + }, + { + name: "existing user and new human with idp", + ctx: CTX, + req: &org.AddOrganizationRequest{ + Name: fmt.Sprintf("%d", time.Now().UnixNano()), + Admins: []*org.AddOrganizationRequest_Admin{ + { + UserType: &org.AddOrganizationRequest_Admin_UserId{UserId: User.GetUserId()}, + }, + { + UserType: &org.AddOrganizationRequest_Admin_Human{ + Human: &user.AddHumanUserRequest{ + Profile: &user.SetHumanProfile{ + FirstName: "firstname", + LastName: "lastname", + }, + Email: &user.SetHumanEmail{ + Email: fmt.Sprintf("%d@mouse.com", time.Now().UnixNano()), + Verification: &user.SetHumanEmail_IsVerified{ + IsVerified: true, + }, + }, + IdpLinks: []*user.IDPLink{ + { + IdpId: idpID, + UserId: "userID", + UserName: "username", + }, + }, + }, + }, + }, + }, + }, + want: &org.AddOrganizationResponse{ + CreatedAdmins: []*org.AddOrganizationResponse_CreatedAdmin{ + // a single admin is expected, because the first provided already exists + { + UserId: integration.NotEmpty, + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Client.AddOrganization(tt.ctx, tt.req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + + // check details + assert.NotZero(t, got.GetDetails().GetSequence()) + gotCD := got.GetDetails().GetChangeDate().AsTime() + now := time.Now() + assert.WithinRange(t, gotCD, now.Add(-time.Minute), now.Add(time.Minute)) + assert.NotEmpty(t, got.GetDetails().GetResourceOwner()) + + // organization id must be the same as the resourceOwner + assert.Equal(t, got.GetDetails().GetResourceOwner(), got.GetOrganizationId()) + + // check the admins + require.Len(t, got.GetCreatedAdmins(), len(tt.want.GetCreatedAdmins())) + for i, admin := range tt.want.GetCreatedAdmins() { + gotAdmin := got.GetCreatedAdmins()[i] + assertCreatedAdmin(t, admin, gotAdmin) + } + }) + } +} + +func assertCreatedAdmin(t *testing.T, expected, got *org.AddOrganizationResponse_CreatedAdmin) { + if expected.GetUserId() != "" { + assert.NotEmpty(t, got.GetUserId()) + } else { + assert.Empty(t, got.GetUserId()) + } + if expected.GetEmailCode() != "" { + assert.NotEmpty(t, got.GetEmailCode()) + } else { + assert.Empty(t, got.GetEmailCode()) + } + if expected.GetPhoneCode() != "" { + assert.NotEmpty(t, got.GetPhoneCode()) + } else { + assert.Empty(t, got.GetPhoneCode()) + } +} diff --git a/internal/api/grpc/org/v2/org_test.go b/internal/api/grpc/org/v2/org_test.go new file mode 100644 index 0000000000..c48421d666 --- /dev/null +++ b/internal/api/grpc/org/v2/org_test.go @@ -0,0 +1,172 @@ +package org + +import ( + "testing" + "time" + + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/domain" + caos_errs "github.com/zitadel/zitadel/internal/errors" + object "github.com/zitadel/zitadel/pkg/grpc/object/v2alpha" + org "github.com/zitadel/zitadel/pkg/grpc/org/v2beta" + user "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha" +) + +func Test_addOrganizationRequestToCommand(t *testing.T) { + type args struct { + request *org.AddOrganizationRequest + } + tests := []struct { + name string + args args + want *command.OrgSetup + wantErr error + }{ + { + name: "nil user", + args: args{ + request: &org.AddOrganizationRequest{ + Name: "name", + Admins: []*org.AddOrganizationRequest_Admin{ + {}, + }, + }, + }, + wantErr: caos_errs.ThrowUnimplementedf(nil, "ORGv2-SD2r1", "userType oneOf %T in method AddOrganization not implemented", nil), + }, + { + name: "user ID", + args: args{ + request: &org.AddOrganizationRequest{ + Name: "name", + Admins: []*org.AddOrganizationRequest_Admin{ + { + UserType: &org.AddOrganizationRequest_Admin_UserId{ + UserId: "userID", + }, + Roles: nil, + }, + }, + }, + }, + want: &command.OrgSetup{ + Name: "name", + CustomDomain: "", + Admins: []*command.OrgSetupAdmin{ + { + ID: "userID", + }, + }, + }, + }, + { + name: "human user", + args: args{ + request: &org.AddOrganizationRequest{ + Name: "name", + Admins: []*org.AddOrganizationRequest_Admin{ + { + UserType: &org.AddOrganizationRequest_Admin_Human{ + Human: &user.AddHumanUserRequest{ + Profile: &user.SetHumanProfile{ + FirstName: "firstname", + LastName: "lastname", + }, + Email: &user.SetHumanEmail{ + Email: "email@test.com", + }, + }, + }, + Roles: nil, + }, + }, + }, + }, + want: &command.OrgSetup{ + Name: "name", + CustomDomain: "", + Admins: []*command.OrgSetupAdmin{ + { + Human: &command.AddHuman{ + Username: "email@test.com", + FirstName: "firstname", + LastName: "lastname", + Email: command.Email{ + Address: "email@test.com", + }, + Metadata: make([]*command.AddMetadataEntry, 0), + Links: make([]*command.AddLink, 0), + }, + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := addOrganizationRequestToCommand(tt.args.request) + require.ErrorIs(t, err, tt.wantErr) + assert.Equal(t, tt.want, got) + }) + } +} + +func Test_createdOrganizationToPb(t *testing.T) { + now := time.Now() + type args struct { + createdOrg *command.CreatedOrg + } + tests := []struct { + name string + args args + want *org.AddOrganizationResponse + wantErr error + }{ + { + name: "human user with phone and email code", + args: args{ + createdOrg: &command.CreatedOrg{ + ObjectDetails: &domain.ObjectDetails{ + Sequence: 1, + EventDate: now, + ResourceOwner: "orgID", + }, + CreatedAdmins: []*command.CreatedOrgAdmin{ + { + ID: "id", + EmailCode: gu.Ptr("emailCode"), + PhoneCode: gu.Ptr("phoneCode"), + }, + }, + }, + }, + want: &org.AddOrganizationResponse{ + Details: &object.Details{ + Sequence: 1, + ChangeDate: timestamppb.New(now), + ResourceOwner: "orgID", + }, + OrganizationId: "orgID", + CreatedAdmins: []*org.AddOrganizationResponse_CreatedAdmin{ + { + UserId: "id", + EmailCode: gu.Ptr("emailCode"), + PhoneCode: gu.Ptr("phoneCode"), + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := createdOrganizationToPb(tt.args.createdOrg) + require.ErrorIs(t, err, tt.wantErr) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/internal/api/grpc/org/v2/server.go b/internal/api/grpc/org/v2/server.go new file mode 100644 index 0000000000..89dba81702 --- /dev/null +++ b/internal/api/grpc/org/v2/server.go @@ -0,0 +1,55 @@ +package org + +import ( + "google.golang.org/grpc" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/api/grpc/server" + "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/query" + org "github.com/zitadel/zitadel/pkg/grpc/org/v2beta" +) + +var _ org.OrganizationServiceServer = (*Server)(nil) + +type Server struct { + org.UnimplementedOrganizationServiceServer + command *command.Commands + query *query.Queries + checkPermission domain.PermissionCheck +} + +type Config struct{} + +func CreateServer( + command *command.Commands, + query *query.Queries, + checkPermission domain.PermissionCheck, +) *Server { + return &Server{ + command: command, + query: query, + checkPermission: checkPermission, + } +} + +func (s *Server) RegisterServer(grpcServer *grpc.Server) { + org.RegisterOrganizationServiceServer(grpcServer, s) +} + +func (s *Server) AppName() string { + return org.OrganizationService_ServiceDesc.ServiceName +} + +func (s *Server) MethodPrefix() string { + return org.OrganizationService_ServiceDesc.ServiceName +} + +func (s *Server) AuthMethods() authz.MethodMapping { + return org.OrganizationService_AuthMethods +} + +func (s *Server) RegisterGateway() server.RegisterGatewayFunc { + return org.RegisterOrganizationServiceHandler +} diff --git a/internal/api/grpc/user/v2/user.go b/internal/api/grpc/user/v2/user.go index a5b56014a5..af2da629f4 100644 --- a/internal/api/grpc/user/v2/user.go +++ b/internal/api/grpc/user/v2/user.go @@ -19,7 +19,7 @@ import ( ) func (s *Server) AddHumanUser(ctx context.Context, req *user.AddHumanUserRequest) (_ *user.AddHumanUserResponse, err error) { - human, err := addUserRequestToAddHuman(req) + human, err := AddUserRequestToAddHuman(req) if err != nil { return nil, err } @@ -36,7 +36,7 @@ func (s *Server) AddHumanUser(ctx context.Context, req *user.AddHumanUserRequest }, nil } -func addUserRequestToAddHuman(req *user.AddHumanUserRequest) (*command.AddHuman, error) { +func AddUserRequestToAddHuman(req *user.AddHumanUserRequest) (*command.AddHuman, error) { username := req.GetUsername() if username == "" { username = req.GetEmail().GetEmail() diff --git a/internal/api/ui/login/register_org_handler.go b/internal/api/ui/login/register_org_handler.go index 065d9d0cd6..d216394430 100644 --- a/internal/api/ui/login/register_org_handler.go +++ b/internal/api/ui/login/register_org_handler.go @@ -66,7 +66,7 @@ func (l *Login) handleRegisterOrgCheck(w http.ResponseWriter, r *http.Request) { l.renderRegisterOrg(w, r, authRequest, data, err) return } - _, _, err = l.command.SetUpOrg(ctx, data.toCommandOrg(), userIDs...) + _, err = l.command.SetUpOrg(ctx, data.toCommandOrg(), true, userIDs...) if err != nil { l.renderRegisterOrg(w, r, authRequest, data, err) return @@ -144,15 +144,19 @@ func (d registerOrgFormData) toCommandOrg() *command.OrgSetup { } return &command.OrgSetup{ Name: d.RegisterOrgName, - Human: &command.AddHuman{ - Username: d.Username, - FirstName: d.Firstname, - LastName: d.Lastname, - Email: command.Email{ - Address: d.Email, + Admins: []*command.OrgSetupAdmin{ + { + Human: &command.AddHuman{ + Username: d.Username, + FirstName: d.Firstname, + LastName: d.Lastname, + Email: command.Email{ + Address: d.Email, + }, + Password: d.Password, + Register: true, + }, }, - Password: d.Password, - Register: true, }, } } diff --git a/internal/command/instance.go b/internal/command/instance.go index 8bc43fbd38..e0a471fe93 100644 --- a/internal/command/instance.go +++ b/internal/command/instance.go @@ -38,7 +38,7 @@ type InstanceSetup struct { InstanceName string CustomDomain string DefaultLanguage language.Tag - Org OrgSetup + Org InstanceOrgSetup SecretGenerators struct { PasswordSaltCost uint ClientSecret *crypto.GeneratorConfig diff --git a/internal/command/org.go b/internal/command/org.go index ba64dccb5f..1ba999e97c 100644 --- a/internal/command/org.go +++ b/internal/command/org.go @@ -14,7 +14,10 @@ import ( "github.com/zitadel/zitadel/internal/repository/user" ) -type OrgSetup struct { +// InstanceOrgSetup is used for the first organisation in the instance setup. +// It used to be called OrgSetup, which now allows multiple Users, but it's used in the config.yaml and therefore +// a breaking change was not possible. +type InstanceOrgSetup struct { Name string CustomDomain string Human *AddHuman @@ -22,83 +25,210 @@ type OrgSetup struct { Roles []string } -func (c *Commands) setUpOrgWithIDs(ctx context.Context, o *OrgSetup, orgID string, userIDs ...string) (userID string, token string, machineKey *MachineKey, details *domain.ObjectDetails, err error) { - userID, err = c.idGenerator.Next() - if err != nil { - return "", "", nil, nil, err +type OrgSetup struct { + Name string + CustomDomain string + Admins []*OrgSetupAdmin +} + +// OrgSetupAdmin describes a user to be created (Human / Machine) or an existing (ID) to be used for an org setup. +type OrgSetupAdmin struct { + ID string + Human *AddHuman + Machine *AddMachine + Roles []string +} + +type orgSetupCommands struct { + validations []preparation.Validation + aggregate *org.Aggregate + commands *Commands + + admins []*OrgSetupAdmin + pats []*PersonalAccessToken + machineKeys []*MachineKey +} + +type CreatedOrg struct { + ObjectDetails *domain.ObjectDetails + CreatedAdmins []*CreatedOrgAdmin +} + +type CreatedOrgAdmin struct { + ID string + EmailCode *string + PhoneCode *string + PAT *PersonalAccessToken + MachineKey *MachineKey +} + +func (c *Commands) setUpOrgWithIDs(ctx context.Context, o *OrgSetup, orgID string, allowInitialMail bool, userIDs ...string) (_ *CreatedOrg, err error) { + cmds := c.newOrgSetupCommands(ctx, orgID, o, userIDs) + for _, admin := range o.Admins { + if err = cmds.setupOrgAdmin(admin, allowInitialMail); err != nil { + return nil, err + } } + if err = cmds.addCustomDomain(o.CustomDomain, userIDs); err != nil { + return nil, err + } + + return cmds.push(ctx) +} + +func (c *Commands) newOrgSetupCommands(ctx context.Context, orgID string, orgSetup *OrgSetup, userIDs []string) *orgSetupCommands { orgAgg := org.NewAggregate(orgID) - userAgg := user.NewAggregate(userID, orgID) - - roles := []string{domain.RoleOrgOwner} - if len(o.Roles) > 0 { - roles = o.Roles - } - validations := []preparation.Validation{ - AddOrgCommand(ctx, orgAgg, o.Name, userIDs...), + AddOrgCommand(ctx, orgAgg, orgSetup.Name, userIDs...), } + return &orgSetupCommands{ + validations: validations, + aggregate: orgAgg, + commands: c, + admins: orgSetup.Admins, + } +} +func (c *orgSetupCommands) setupOrgAdmin(admin *OrgSetupAdmin, allowInitialMail bool) error { + if admin.ID != "" { + c.validations = append(c.validations, c.commands.AddOrgMemberCommand(c.aggregate, admin.ID, orgAdminRoles(admin.Roles)...)) + return nil + } + userID, err := c.commands.idGenerator.Next() + if err != nil { + return err + } + if admin.Human != nil { + admin.Human.ID = userID + c.validations = append(c.validations, c.commands.AddHumanCommand(admin.Human, c.aggregate.ID, c.commands.userPasswordHasher, c.commands.userEncryption, allowInitialMail)) + } else if admin.Machine != nil { + admin.Machine.Machine.AggregateID = userID + if err = c.setupOrgAdminMachine(c.aggregate, admin.Machine); err != nil { + return err + } + } + c.validations = append(c.validations, c.commands.AddOrgMemberCommand(c.aggregate, userID, orgAdminRoles(admin.Roles)...)) + return nil +} + +func (c *orgSetupCommands) setupOrgAdminMachine(orgAgg *org.Aggregate, machine *AddMachine) error { + userAgg := user.NewAggregate(machine.Machine.AggregateID, orgAgg.ID) + c.validations = append(c.validations, AddMachineCommand(userAgg, machine.Machine)) var pat *PersonalAccessToken - if o.Human != nil { - o.Human.ID = userID - validations = append(validations, c.AddHumanCommand(o.Human, orgID, c.userPasswordHasher, c.userEncryption, true)) - } else if o.Machine != nil { - validations = append(validations, AddMachineCommand(userAgg, o.Machine.Machine)) - if o.Machine.Pat != nil { - pat = NewPersonalAccessToken(orgID, userID, o.Machine.Pat.ExpirationDate, o.Machine.Pat.Scopes, domain.UserTypeMachine) - tokenID, err := c.idGenerator.Next() - if err != nil { - return "", "", nil, nil, err - } - pat.TokenID = tokenID - validations = append(validations, prepareAddPersonalAccessToken(pat, c.keyAlgorithm)) + var machineKey *MachineKey + if machine.Pat != nil { + pat = NewPersonalAccessToken(orgAgg.ID, machine.Machine.AggregateID, machine.Pat.ExpirationDate, machine.Pat.Scopes, domain.UserTypeMachine) + tokenID, err := c.commands.idGenerator.Next() + if err != nil { + return err } - if o.Machine.MachineKey != nil { - machineKey = NewMachineKey(orgID, userID, o.Machine.MachineKey.ExpirationDate, o.Machine.MachineKey.Type) - keyID, err := c.idGenerator.Next() - if err != nil { - return "", "", nil, nil, err - } - machineKey.KeyID = keyID - validations = append(validations, prepareAddUserMachineKey(machineKey, c.keySize)) + pat.TokenID = tokenID + c.pats = append(c.pats, pat) + c.validations = append(c.validations, prepareAddPersonalAccessToken(pat, c.commands.keyAlgorithm)) + } + if machine.MachineKey != nil { + machineKey = NewMachineKey(orgAgg.ID, machine.Machine.AggregateID, machine.MachineKey.ExpirationDate, machine.MachineKey.Type) + keyID, err := c.commands.idGenerator.Next() + if err != nil { + return err } + machineKey.KeyID = keyID + c.machineKeys = append(c.machineKeys, machineKey) + c.validations = append(c.validations, prepareAddUserMachineKey(machineKey, c.commands.keySize)) } - validations = append(validations, c.AddOrgMemberCommand(orgAgg, userID, roles...)) + return nil +} - if o.CustomDomain != "" { - validations = append(validations, c.prepareAddOrgDomain(orgAgg, o.CustomDomain, userIDs)) +func (c *orgSetupCommands) addCustomDomain(domain string, userIDs []string) error { + if domain != "" { + c.validations = append(c.validations, c.commands.prepareAddOrgDomain(c.aggregate, domain, userIDs)) } + return nil +} - cmds, err := preparation.PrepareCommands(ctx, c.eventstore.Filter, validations...) +func orgAdminRoles(roles []string) []string { + if len(roles) > 0 { + return roles + } + return []string{domain.RoleOrgOwner} +} + +func (c *orgSetupCommands) push(ctx context.Context) (_ *CreatedOrg, err error) { + cmds, err := preparation.PrepareCommands(ctx, c.commands.eventstore.Filter, c.validations...) if err != nil { - return "", "", nil, nil, err + return nil, err } - events, err := c.eventstore.Push(ctx, cmds...) + events, err := c.commands.eventstore.Push(ctx, cmds...) if err != nil { - return "", "", nil, nil, err + return nil, err } - if pat != nil { - token = pat.Token - } - - return userID, token, machineKey, &domain.ObjectDetails{ - Sequence: events[len(events)-1].Sequence(), - EventDate: events[len(events)-1].CreationDate(), - ResourceOwner: orgID, + return &CreatedOrg{ + ObjectDetails: &domain.ObjectDetails{ + Sequence: events[len(events)-1].Sequence(), + EventDate: events[len(events)-1].CreationDate(), + ResourceOwner: c.aggregate.ID, + }, + CreatedAdmins: c.createdAdmins(), }, nil } -func (c *Commands) SetUpOrg(ctx context.Context, o *OrgSetup, userIDs ...string) (string, *domain.ObjectDetails, error) { +func (c *orgSetupCommands) createdAdmins() []*CreatedOrgAdmin { + users := make([]*CreatedOrgAdmin, 0, len(c.admins)) + for _, admin := range c.admins { + if admin.ID != "" { + continue + } + if admin.Human != nil { + users = append(users, c.createdHumanAdmin(admin)) + continue + } + if admin.Machine != nil { + users = append(users, c.createdMachineAdmin(admin)) + } + } + return users +} + +func (c *orgSetupCommands) createdHumanAdmin(admin *OrgSetupAdmin) *CreatedOrgAdmin { + createdAdmin := &CreatedOrgAdmin{ + ID: admin.Human.ID, + } + if admin.Human.EmailCode != nil { + createdAdmin.EmailCode = admin.Human.EmailCode + } + return createdAdmin +} + +func (c *orgSetupCommands) createdMachineAdmin(admin *OrgSetupAdmin) *CreatedOrgAdmin { + createdAdmin := &CreatedOrgAdmin{ + ID: admin.Machine.Machine.AggregateID, + } + if admin.Machine.Pat != nil { + for _, pat := range c.pats { + if pat.AggregateID == createdAdmin.ID { + createdAdmin.PAT = pat + } + } + } + if admin.Machine.MachineKey != nil { + for _, key := range c.machineKeys { + if key.AggregateID == createdAdmin.ID { + createdAdmin.MachineKey = key + } + } + } + return createdAdmin +} + +func (c *Commands) SetUpOrg(ctx context.Context, o *OrgSetup, allowInitialMail bool, userIDs ...string) (*CreatedOrg, error) { orgID, err := c.idGenerator.Next() if err != nil { - return "", nil, err + return nil, err } - userID, _, _, details, err := c.setUpOrgWithIDs(ctx, o, orgID, userIDs...) - return userID, details, err + return c.setUpOrgWithIDs(ctx, o, orgID, allowInitialMail, userIDs...) } // AddOrgCommand defines the commands to create a new org, diff --git a/internal/command/org_test.go b/internal/command/org_test.go index 031703d35c..e67ba69dc8 100644 --- a/internal/command/org_test.go +++ b/internal/command/org_test.go @@ -3,11 +3,15 @@ package command import ( "context" "testing" + "time" + "github.com/golang/mock/gomock" "github.com/stretchr/testify/assert" + openid "github.com/zitadel/oidc/v2/pkg/oidc" "golang.org/x/text/language" "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/errors" "github.com/zitadel/zitadel/internal/eventstore" @@ -1325,3 +1329,450 @@ func TestCommandSide_RemoveOrg(t *testing.T) { }) } } + +func TestCommandSide_SetUpOrg(t *testing.T) { + type fields struct { + eventstore func(t *testing.T) *eventstore.Eventstore + idGenerator id.Generator + newCode cryptoCodeFunc + keyAlgorithm crypto.EncryptionAlgorithm + } + type args struct { + ctx context.Context + setupOrg *OrgSetup + allowInitialMail bool + userIDs []string + } + type res struct { + createdOrg *CreatedOrg + err error + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + name: "org name empty, error", + fields: fields{ + eventstore: expectEventstore(), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "orgID"), + }, + args: args{ + ctx: authz.WithRequestedDomain(context.Background(), "iam-domain"), + setupOrg: &OrgSetup{ + Name: "", + }, + }, + res: res{ + err: errors.ThrowInvalidArgument(nil, "ORG-mruNY", "Errors.Invalid.Argument"), + }, + }, + { + name: "userID not existing, error", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + ), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "orgID"), + }, + args: args{ + ctx: authz.WithRequestedDomain(context.Background(), "iam-domain"), + setupOrg: &OrgSetup{ + Name: "Org", + Admins: []*OrgSetupAdmin{ + { + ID: "userID", + }, + }, + }, + }, + res: res{ + err: errors.ThrowPreconditionFailed(nil, "ORG-GoXOn", "Errors.User.NotFound"), + }, + }, + { + name: "human invalid, error", + fields: fields{ + eventstore: expectEventstore(), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "orgID", "userID"), + }, + args: args{ + ctx: authz.WithRequestedDomain(context.Background(), "iam-domain"), + setupOrg: &OrgSetup{ + Name: "Org", + Admins: []*OrgSetupAdmin{ + { + Human: &AddHuman{ + Username: "", + FirstName: "firstname", + LastName: "lastname", + Email: Email{ + Address: "email@test.ch", + Verified: true, + }, + PreferredLanguage: language.English, + }, + }, + }, + }, + allowInitialMail: true, + }, + res: res{ + err: errors.ThrowInvalidArgument(nil, "V2-zzad3", "Errors.Invalid.Argument"), + }, + }, + { + name: "human added with initial mail", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), // add human exists check + expectFilter( + eventFromEventPusher( + org.NewDomainPolicyAddedEvent(context.Background(), + &user.NewAggregate("userID", "orgID").Aggregate, + true, + true, + true, + ), + ), + ), + expectFilter(), // org member check + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "orgID").Aggregate, + "username", + "firstname", + "lastname", + "", + "firstname lastname", + language.English, + domain.GenderUnspecified, + "email@test.ch", + true, + ), + ), + ), + expectPush( + []*repository.Event{ + eventFromEventPusher(org.NewOrgAddedEvent(context.Background(), + &org.NewAggregate("orgID").Aggregate, + "Org", + )), + eventFromEventPusher(org.NewDomainAddedEvent(context.Background(), + &org.NewAggregate("orgID").Aggregate, "org.iam-domain", + )), + eventFromEventPusher(org.NewDomainVerifiedEvent(context.Background(), + &org.NewAggregate("orgID").Aggregate, + "org.iam-domain", + )), + eventFromEventPusher(org.NewDomainPrimarySetEvent(context.Background(), + &org.NewAggregate("orgID").Aggregate, + "org.iam-domain", + )), + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("userID", "orgID").Aggregate, + "username", + "firstname", + "lastname", + "", + "firstname lastname", + language.English, + domain.GenderUnspecified, + "email@test.ch", + true, + ), + ), + eventFromEventPusher( + user.NewHumanEmailVerifiedEvent(context.Background(), + &user.NewAggregate("userID", "orgID").Aggregate, + ), + ), + eventFromEventPusher( + user.NewHumanInitialCodeAddedEvent( + context.Background(), + &user.NewAggregate("userID", "orgID").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("userinit"), + }, + 1*time.Hour, + ), + ), + eventFromEventPusher(org.NewMemberAddedEvent(context.Background(), + &org.NewAggregate("orgID").Aggregate, + "userID", + domain.RoleOrgOwner, + )), + }, + uniqueConstraintsFromEventConstraint(org.NewAddOrgNameUniqueConstraint("Org")), + uniqueConstraintsFromEventConstraint(org.NewAddOrgDomainUniqueConstraint("org.iam-domain")), + uniqueConstraintsFromEventConstraint(user.NewAddUsernameUniqueConstraint("username", "orgID", true)), + uniqueConstraintsFromEventConstraint(member.NewAddMemberUniqueConstraint("orgID", "userID")), + ), + ), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "orgID", "userID"), + newCode: mockCode("userinit", time.Hour), + }, + args: args{ + ctx: authz.WithRequestedDomain(context.Background(), "iam-domain"), + setupOrg: &OrgSetup{ + Name: "Org", + Admins: []*OrgSetupAdmin{ + { + Human: &AddHuman{ + Username: "username", + FirstName: "firstname", + LastName: "lastname", + Email: Email{ + Address: "email@test.ch", + Verified: true, + }, + PreferredLanguage: language.English, + }, + }, + }, + }, + allowInitialMail: true, + }, + res: res{ + createdOrg: &CreatedOrg{ + ObjectDetails: &domain.ObjectDetails{ + ResourceOwner: "orgID", + }, + CreatedAdmins: []*CreatedOrgAdmin{ + { + ID: "userID", + }, + }, + }, + }, + }, + { + name: "existing human added", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "orgID").Aggregate, + "username", + "firstname", + "lastname", + "", + "firstname lastname", + language.English, + domain.GenderUnspecified, + "email@test.ch", + true, + ), + ), + ), + expectFilter(), // org member check + expectPush( + []*repository.Event{ + eventFromEventPusher(org.NewOrgAddedEvent(context.Background(), + &org.NewAggregate("orgID").Aggregate, + "Org", + )), + eventFromEventPusher(org.NewDomainAddedEvent(context.Background(), + &org.NewAggregate("orgID").Aggregate, "org.iam-domain", + )), + eventFromEventPusher(org.NewDomainVerifiedEvent(context.Background(), + &org.NewAggregate("orgID").Aggregate, + "org.iam-domain", + )), + eventFromEventPusher(org.NewDomainPrimarySetEvent(context.Background(), + &org.NewAggregate("orgID").Aggregate, + "org.iam-domain", + )), + eventFromEventPusher(org.NewMemberAddedEvent(context.Background(), + &org.NewAggregate("orgID").Aggregate, + "userID", + domain.RoleOrgOwner, + )), + }, + uniqueConstraintsFromEventConstraint(org.NewAddOrgNameUniqueConstraint("Org")), + uniqueConstraintsFromEventConstraint(org.NewAddOrgDomainUniqueConstraint("org.iam-domain")), + uniqueConstraintsFromEventConstraint(member.NewAddMemberUniqueConstraint("orgID", "userID")), + ), + ), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "orgID"), + }, + args: args{ + ctx: authz.WithRequestedDomain(context.Background(), "iam-domain"), + setupOrg: &OrgSetup{ + Name: "Org", + Admins: []*OrgSetupAdmin{ + { + ID: "userID", + }, + }, + }, + allowInitialMail: true, + }, + res: res{ + createdOrg: &CreatedOrg{ + ObjectDetails: &domain.ObjectDetails{ + ResourceOwner: "orgID", + }, + CreatedAdmins: []*CreatedOrgAdmin{}, + }, + }, + }, + { + name: "machine added with pat", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), // add machine exists check + expectFilter( + eventFromEventPusher( + org.NewDomainPolicyAddedEvent(context.Background(), + &user.NewAggregate("userID", "orgID").Aggregate, + true, + true, + true, + ), + ), + ), + expectFilter(), + expectFilter(), + expectFilter(), // org member check + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "orgID").Aggregate, + "username", + "firstname", + "lastname", + "", + "firstname lastname", + language.English, + domain.GenderUnspecified, + "email@test.ch", + true, + ), + ), + ), + expectPush( + []*repository.Event{ + eventFromEventPusher(org.NewOrgAddedEvent(context.Background(), + &org.NewAggregate("orgID").Aggregate, + "Org", + )), + eventFromEventPusher(org.NewDomainAddedEvent(context.Background(), + &org.NewAggregate("orgID").Aggregate, "org.iam-domain", + )), + eventFromEventPusher(org.NewDomainVerifiedEvent(context.Background(), + &org.NewAggregate("orgID").Aggregate, + "org.iam-domain", + )), + eventFromEventPusher(org.NewDomainPrimarySetEvent(context.Background(), + &org.NewAggregate("orgID").Aggregate, + "org.iam-domain", + )), + eventFromEventPusher( + user.NewMachineAddedEvent(context.Background(), + &user.NewAggregate("userID", "orgID").Aggregate, + "username", + "name", + "description", + true, + domain.OIDCTokenTypeBearer, + ), + ), + eventFromEventPusher( + user.NewPersonalAccessTokenAddedEvent(context.Background(), + &user.NewAggregate("userID", "orgID").Aggregate, + "tokenID", + testNow.Add(time.Hour), + []string{openid.ScopeOpenID}, + ), + ), + eventFromEventPusher(org.NewMemberAddedEvent(context.Background(), + &org.NewAggregate("orgID").Aggregate, + "userID", + domain.RoleOrgOwner, + )), + }, + uniqueConstraintsFromEventConstraint(org.NewAddOrgNameUniqueConstraint("Org")), + uniqueConstraintsFromEventConstraint(org.NewAddOrgDomainUniqueConstraint("org.iam-domain")), + uniqueConstraintsFromEventConstraint(user.NewAddUsernameUniqueConstraint("username", "orgID", true)), + uniqueConstraintsFromEventConstraint(member.NewAddMemberUniqueConstraint("orgID", "userID")), + ), + ), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "orgID", "userID", "tokenID"), + newCode: mockCode("userinit", time.Hour), + keyAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + }, + args: args{ + ctx: authz.WithRequestedDomain(context.Background(), "iam-domain"), + setupOrg: &OrgSetup{ + Name: "Org", + Admins: []*OrgSetupAdmin{ + { + Machine: &AddMachine{ + Machine: &Machine{ + Username: "username", + Name: "name", + Description: "description", + AccessTokenType: domain.OIDCTokenTypeBearer, + }, + Pat: &AddPat{ + ExpirationDate: testNow.Add(time.Hour), + Scopes: []string{openid.ScopeOpenID}, + }, + }, + }, + }, + }, + }, + res: res{ + createdOrg: &CreatedOrg{ + ObjectDetails: &domain.ObjectDetails{ + ResourceOwner: "orgID", + }, + CreatedAdmins: []*CreatedOrgAdmin{ + { + ID: "userID", + PAT: &PersonalAccessToken{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "userID", + ResourceOwner: "orgID", + }, + ExpirationDate: testNow.Add(time.Hour), + Scopes: []string{openid.ScopeOpenID}, + AllowedUserType: domain.UserTypeMachine, + TokenID: "tokenID", + Token: "dG9rZW5JRDp1c2VySUQ", // token + }, + }, + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Commands{ + eventstore: tt.fields.eventstore(t), + idGenerator: tt.fields.idGenerator, + newCode: tt.fields.newCode, + keyAlgorithm: tt.fields.keyAlgorithm, + zitadelRoles: []authz.RoleMapping{ + { + Role: domain.RoleOrgOwner, + }, + }, + } + got, err := r.SetUpOrg(tt.args.ctx, tt.args.setupOrg, tt.args.allowInitialMail, tt.args.userIDs...) + assert.ErrorIs(t, err, tt.res.err) + assert.Equal(t, tt.res.createdOrg, got) + }) + } +} diff --git a/internal/integration/client.go b/internal/integration/client.go index 0daf37c404..dd7ce06d6a 100644 --- a/internal/integration/client.go +++ b/internal/integration/client.go @@ -21,6 +21,7 @@ import ( mgmt "github.com/zitadel/zitadel/pkg/grpc/management" object "github.com/zitadel/zitadel/pkg/grpc/object/v2alpha" oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2alpha" + organisation "github.com/zitadel/zitadel/pkg/grpc/org/v2beta" session "github.com/zitadel/zitadel/pkg/grpc/session/v2alpha" "github.com/zitadel/zitadel/pkg/grpc/system" user "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha" @@ -34,6 +35,7 @@ type Client struct { UserV2 user.UserServiceClient SessionV2 session.SessionServiceClient OIDCv2 oidc_pb.OIDCServiceClient + OrgV2 organisation.OrganizationServiceClient System system.SystemServiceClient } @@ -46,6 +48,7 @@ func newClient(cc *grpc.ClientConn) Client { UserV2: user.NewUserServiceClient(cc), SessionV2: session.NewSessionServiceClient(cc), OIDCv2: oidc_pb.NewOIDCServiceClient(cc), + OrgV2: organisation.NewOrganizationServiceClient(cc), System: system.NewSystemServiceClient(cc), } } @@ -153,7 +156,7 @@ func (s *Tester) SetUserPassword(ctx context.Context, userID, password string) { func (s *Tester) AddGenericOAuthProvider(t *testing.T) string { ctx := authz.WithInstance(context.Background(), s.Instance) - id, _, err := s.Commands.AddOrgGenericOAuthProvider(ctx, s.Organisation.ID, command.GenericOAuthProvider{ + id, _, err := s.Commands.AddInstanceGenericOAuthProvider(ctx, command.GenericOAuthProvider{ Name: "idp", ClientID: "clientID", ClientSecret: "clientSecret", diff --git a/internal/integration/integration.go b/internal/integration/integration.go index 0912f8dc0b..c645a188fe 100644 --- a/internal/integration/integration.go +++ b/internal/integration/integration.go @@ -46,6 +46,10 @@ var ( systemUserKey []byte ) +// NotEmpty can be used as placeholder, when the returned values is unknown. +// It can be used in tests to assert whether a value should be empty or not. +const NotEmpty = "not empty" + // UserType provides constants that give // a short explinanation with the purpose // a serverice user. @@ -153,11 +157,38 @@ func (s *Tester) pollHealth(ctx context.Context) (err error) { } const ( - LoginUser = "loginClient" - MachineUser = "integration" + LoginUser = "loginClient" + MachineUserOrgOwner = "integrationOrgOwner" + MachineUserInstanceOwner = "integrationInstanceOwner" ) -func (s *Tester) createMachineUser(ctx context.Context, instanceId string) { +func (s *Tester) createMachineUserOrgOwner(ctx context.Context) { + var err error + + ctx, user := s.createMachineUser(ctx, MachineUserOrgOwner, OrgOwner) + _, err = s.Commands.AddOrgMember(ctx, user.ResourceOwner, user.ID, "ORG_OWNER") + target := new(caos_errs.AlreadyExistsError) + if !errors.As(err, &target) { + logging.OnError(err).Fatal("add org member") + } +} + +func (s *Tester) createMachineUserInstanceOwner(ctx context.Context) { + var err error + + ctx, user := s.createMachineUser(ctx, MachineUserInstanceOwner, IAMOwner) + _, err = s.Commands.AddInstanceMember(ctx, user.ID, "IAM_OWNER") + target := new(caos_errs.AlreadyExistsError) + if !errors.As(err, &target) { + logging.OnError(err).Fatal("add instance member") + } +} + +func (s *Tester) createLoginClient(ctx context.Context) { + s.createMachineUser(ctx, LoginUser, Login) +} + +func (s *Tester) createMachineUser(ctx context.Context, username string, userType UserType) (context.Context, *query.User) { var err error s.Instance, err = s.Queries.InstanceByHost(ctx, s.Host()) @@ -167,7 +198,7 @@ func (s *Tester) createMachineUser(ctx context.Context, instanceId string) { s.Organisation, err = s.Queries.OrgByID(ctx, true, s.Instance.DefaultOrganisationID()) logging.OnError(err).Fatal("query organisation") - usernameQuery, err := query.NewUserUsernameSearchQuery(MachineUser, query.TextEquals) + usernameQuery, err := query.NewUserUsernameSearchQuery(username, query.TextEquals) logging.OnError(err).Fatal("user query") user, err := s.Queries.GetUser(ctx, true, true, usernameQuery) if errors.Is(err, sql.ErrNoRows) { @@ -175,69 +206,25 @@ func (s *Tester) createMachineUser(ctx context.Context, instanceId string) { ObjectRoot: models.ObjectRoot{ ResourceOwner: s.Organisation.ID, }, - Username: MachineUser, - Name: MachineUser, + Username: username, + Name: username, Description: "who cares?", AccessTokenType: domain.OIDCTokenTypeJWT, }) - logging.WithFields("username", SystemUser).OnError(err).Fatal("add machine user") + logging.WithFields("username", username).OnError(err).Fatal("add machine user") user, err = s.Queries.GetUser(ctx, true, true, usernameQuery) } - logging.WithFields("username", SystemUser).OnError(err).Fatal("get user") - - _, err = s.Commands.AddOrgMember(ctx, s.Organisation.ID, user.ID, "ORG_OWNER") - target := new(caos_errs.AlreadyExistsError) - if !errors.As(err, &target) { - logging.OnError(err).Fatal("add org member") - } + logging.WithFields("username", username).OnError(err).Fatal("get user") scopes := []string{oidc.ScopeOpenID, oidc.ScopeProfile, z_oidc.ScopeUserMetaData, z_oidc.ScopeResourceOwner} pat := command.NewPersonalAccessToken(user.ResourceOwner, user.ID, time.Now().Add(time.Hour), scopes, domain.UserTypeMachine) _, err = s.Commands.AddPersonalAccessToken(ctx, pat) logging.WithFields("username", SystemUser).OnError(err).Fatal("add pat") - s.Users.Set(instanceId, OrgOwner, &User{ - User: user, - Token: pat.Token, - }) -} - -func (s *Tester) createLoginClient(ctx context.Context) { - var err error - - s.Instance, err = s.Queries.InstanceByHost(ctx, s.Host()) - logging.OnError(err).Fatal("query instance") - ctx = authz.WithInstance(ctx, s.Instance) - - s.Organisation, err = s.Queries.OrgByID(ctx, true, s.Instance.DefaultOrganisationID()) - logging.OnError(err).Fatal("query organisation") - - usernameQuery, err := query.NewUserUsernameSearchQuery(LoginUser, query.TextEquals) - logging.WithFields("username", LoginUser).OnError(err).Fatal("user query") - user, err := s.Queries.GetUser(ctx, true, true, usernameQuery) - if errors.Is(err, sql.ErrNoRows) { - _, err = s.Commands.AddMachine(ctx, &command.Machine{ - ObjectRoot: models.ObjectRoot{ - ResourceOwner: s.Organisation.ID, - }, - Username: LoginUser, - Name: LoginUser, - Description: "who cares?", - AccessTokenType: domain.OIDCTokenTypeJWT, - }) - logging.WithFields("username", LoginUser).OnError(err).Fatal("add machine user") - user, err = s.Queries.GetUser(ctx, true, true, usernameQuery) - } - logging.WithFields("username", LoginUser).OnError(err).Fatal("get user") - - scopes := []string{oidc.ScopeOpenID, z_oidc.ScopeUserMetaData, z_oidc.ScopeResourceOwner} - pat := command.NewPersonalAccessToken(user.ResourceOwner, user.ID, time.Now().Add(time.Hour), scopes, domain.UserTypeMachine) - _, err = s.Commands.AddPersonalAccessToken(ctx, pat) - logging.OnError(err).Fatal("add pat") - - s.Users.Set(FirstInstanceUsersKey, Login, &User{ + s.Users.Set(FirstInstanceUsersKey, userType, &User{ User: user, Token: pat.Token, }) + return ctx, user } func (s *Tester) WithAuthorization(ctx context.Context, u UserType) context.Context { @@ -333,7 +320,8 @@ func NewTester(ctx context.Context) *Tester { tester.createClientConn(ctx) tester.createLoginClient(ctx) tester.WebAuthN = webauthn.NewClient(tester.Config.WebAuthNName, tester.Config.ExternalDomain, http_util.BuildOrigin(tester.Host(), tester.Config.ExternalSecure)) - tester.createMachineUser(ctx, FirstInstanceUsersKey) + tester.createMachineUserOrgOwner(ctx) + tester.createMachineUserInstanceOwner(ctx) tester.WebAuthN = webauthn.NewClient(tester.Config.WebAuthNName, tester.Config.ExternalDomain, "https://"+tester.Host()) return &tester diff --git a/internal/integration/usertype_string.go b/internal/integration/usertype_string.go index 3f5db98d72..6477630986 100644 --- a/internal/integration/usertype_string.go +++ b/internal/integration/usertype_string.go @@ -10,11 +10,14 @@ func _() { var x [1]struct{} _ = x[Unspecified-0] _ = x[OrgOwner-1] + _ = x[Login-2] + _ = x[IAMOwner-3] + _ = x[SystemUser-4] } -const _UserType_name = "UnspecifiedOrgOwner" +const _UserType_name = "UnspecifiedOrgOwnerLoginIAMOwnerSystemUser" -var _UserType_index = [...]uint8{0, 11, 19} +var _UserType_index = [...]uint8{0, 11, 19, 24, 32, 42} func (i UserType) String() string { if i < 0 || i >= UserType(len(_UserType_index)-1) { diff --git a/proto/zitadel/org/v2beta/org_service.proto b/proto/zitadel/org/v2beta/org_service.proto new file mode 100644 index 0000000000..c1bf6e80b0 --- /dev/null +++ b/proto/zitadel/org/v2beta/org_service.proto @@ -0,0 +1,145 @@ +syntax = "proto3"; + + +package zitadel.org.v2beta; + +import "zitadel/object/v2alpha/object.proto"; +import "zitadel/protoc_gen_zitadel/v2/options.proto"; +import "zitadel/user/v2alpha/auth.proto"; +import "zitadel/user/v2alpha/email.proto"; +import "zitadel/user/v2alpha/phone.proto"; +import "zitadel/user/v2alpha/idp.proto"; +import "zitadel/user/v2alpha/password.proto"; +import "zitadel/user/v2alpha/user.proto"; +import "zitadel/user/v2alpha/user_service.proto"; +import "google/api/annotations.proto"; +import "google/api/field_behavior.proto"; +import "google/protobuf/duration.proto"; +import "google/protobuf/struct.proto"; +import "protoc-gen-openapiv2/options/annotations.proto"; +import "validate/validate.proto"; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/org/v2beta;org"; + +option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = { + info: { + title: "User Service"; + version: "2.0-alpha"; + description: "This API is intended to manage organizations in a ZITADEL instance. This project is in beta state. It can AND will continue breaking until the services provide the same functionality as the current login."; + contact:{ + name: "ZITADEL" + url: "https://zitadel.com" + email: "hi@zitadel.com" + } + license: { + name: "Apache 2.0", + url: "https://github.com/zitadel/zitadel/blob/main/LICENSE"; + }; + }; + 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: "$ZITADEL_DOMAIN"; + base_path: "/"; + + external_docs: { + description: "Detailed information about ZITADEL", + url: "https://zitadel.com/docs" + } + + 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"; + } + } + } + } +}; + +service OrganizationService { + + // Create a new organization and grant the user(s) permission to manage it + rpc AddOrganization(AddOrganizationRequest) returns (AddOrganizationResponse) { + option (google.api.http) = { + post: "/v2beta/organizations" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "org.create" + } + http_response: { + success_code: 201 + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "Create an Organization"; + description: "Create a new organization with an administrative user. If no specific roles are sent for the users, they will be granted the role ORG_OWNER." + responses: { + key: "200" + value: { + description: "OK"; + } + }; + }; + } +} + +message AddOrganizationRequest{ + message Admin { + oneof user_type{ + string user_id = 1; + zitadel.user.v2alpha.AddHumanUserRequest human = 2; + } + // specify Org Member Roles for the provided user (default is ORG_OWNER if roles are empty) + repeated string roles = 3; + } + + string name = 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: "\"ZITADEL\""; + } + ]; + repeated Admin admins = 2; +} + +message AddOrganizationResponse{ + message CreatedAdmin { + string user_id = 1; + optional string email_code = 2; + optional string phone_code = 3; + } + zitadel.object.v2alpha.Details details = 1; + string organization_id = 2; + repeated CreatedAdmin created_admins = 3; +}