From 3b005c4e0634557b6970b506f40ab7c4966b1cc0 Mon Sep 17 00:00:00 2001 From: Marco Ardizzone Date: Mon, 28 Apr 2025 16:13:20 +0200 Subject: [PATCH] feat: Add CreateInstance endpoint (#9452) --- internal/api/grpc/authn/v2beta/converter.go | 15 ++ internal/api/grpc/instance/v2/converter.go | 131 ++++++++++++++++++ internal/api/grpc/instance/v2/instance.go | 22 +++ internal/api/grpc/instance/v2/server.go | 6 + proto/zitadel/authn/v2beta/authn.proto | 35 +++++ .../instance/v2/instance_service.proto | 100 ++++++++++++- 6 files changed, 308 insertions(+), 1 deletion(-) create mode 100644 internal/api/grpc/authn/v2beta/converter.go create mode 100644 proto/zitadel/authn/v2beta/authn.proto diff --git a/internal/api/grpc/authn/v2beta/converter.go b/internal/api/grpc/authn/v2beta/converter.go new file mode 100644 index 00000000000..d001d540e89 --- /dev/null +++ b/internal/api/grpc/authn/v2beta/converter.go @@ -0,0 +1,15 @@ +package authn + +import ( + "github.com/zitadel/zitadel/internal/domain" + authn "github.com/zitadel/zitadel/pkg/grpc/authn/v2beta" +) + +func KeyTypeToDomain(t authn.KeyType) domain.AuthNKeyType { + switch t { + case authn.KeyType_KEY_TYPE_JSON: + return domain.AuthNKeyTypeJSON + default: + return domain.AuthNKeyTypeNONE + } +} diff --git a/internal/api/grpc/instance/v2/converter.go b/internal/api/grpc/instance/v2/converter.go index 753f28e8266..d5309832aec 100644 --- a/internal/api/grpc/instance/v2/converter.go +++ b/internal/api/grpc/instance/v2/converter.go @@ -1,13 +1,21 @@ package instance import ( + "strings" + + "github.com/zitadel/oidc/v3/pkg/oidc" "github.com/zitadel/zitadel/cmd/build" + authn "github.com/zitadel/zitadel/internal/api/grpc/authn/v2beta" filter "github.com/zitadel/zitadel/internal/api/grpc/filter/v2beta" "github.com/zitadel/zitadel/internal/api/grpc/object/v2" + z_oidc "github.com/zitadel/zitadel/internal/api/oidc" + "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" "github.com/zitadel/zitadel/internal/zerrors" "github.com/zitadel/zitadel/pkg/grpc/instance/v2" + "golang.org/x/text/language" ) func InstancesToPb(instances []*query.Instance) []*instance.Instance { @@ -109,3 +117,126 @@ func instanceQueryToModel(searchQuery *instance.Query) (query.SearchQuery, error return nil, zerrors.ThrowInvalidArgument(nil, "INST-3m0se", "List.Query.Invalid") } } + +func CreateInstancePbToSetupInstance(req *instance.CreateInstanceRequest, defaultInstance command.InstanceSetup, externalDomain string) *command.InstanceSetup { + instance := defaultInstance + if req.InstanceName != "" { + instance.InstanceName = req.InstanceName + instance.Org.Name = req.InstanceName + } + if req.CustomDomain != "" { + instance.CustomDomain = req.CustomDomain + } + if req.FirstOrgName != "" { + instance.Org.Name = req.FirstOrgName + } + + if user := req.GetMachine(); user != nil { + defaultMachine := instance.Org.Machine + if defaultMachine == nil { + defaultMachine = new(command.AddMachine) + } + + instance.Org.Machine = createInstancePbToAddMachine(user, *defaultMachine) + instance.Org.Human = nil + } else if user := req.GetHuman(); user != nil { + defaultHuman := instance.Org.Human + if instance.Org.Human != nil { + defaultHuman = new(command.AddHuman) + } + + instance.Org.Human = createInstancePbToAddHuman(user, *defaultHuman, instance.DomainPolicy.UserLoginMustBeDomain, instance.Org.Name, externalDomain) + instance.Org.Machine = nil + } + + if lang := language.Make(req.DefaultLanguage); !lang.IsRoot() { + instance.DefaultLanguage = lang + } + + return &instance +} + +func createInstancePbToAddHuman(req *instance.CreateInstanceRequest_Human, defaultHuman command.AddHuman, userLoginMustBeDomain bool, org, externalDomain string) *command.AddHuman { + user := defaultHuman + if req.Email != nil { + user.Email.Address = domain.EmailAddress(req.Email.Email) + user.Email.Verified = req.Email.IsEmailVerified + } + if req.Profile != nil { + if req.Profile.FirstName != "" { + user.FirstName = req.Profile.FirstName + } + if req.Profile.LastName != "" { + user.LastName = req.Profile.LastName + } + if req.Profile.PreferredLanguage != "" { + lang, err := language.Parse(req.Profile.PreferredLanguage) + if err == nil { + user.PreferredLanguage = lang + } + } + } + // check if default username is email style or else append @. + // this way we have the same value as before changing `UserLoginMustBeDomain` to false + if !userLoginMustBeDomain && !strings.Contains(user.Username, "@") { + orgDomain, _ := domain.NewIAMDomainName(org, externalDomain) + user.Username = user.Username + "@" + orgDomain + } + if req.UserName != "" { + user.Username = req.UserName + } + + if req.Password != nil { + user.Password = req.Password.Password + user.PasswordChangeRequired = req.Password.PasswordChangeRequired + } + return &user +} + +func createInstancePbToAddMachine(req *instance.CreateInstanceRequest_Machine, defaultMachine command.AddMachine) (machine *command.AddMachine) { + machine = new(command.AddMachine) + if defaultMachine.Machine != nil { + machineCopy := *defaultMachine.Machine + machine.Machine = &machineCopy + } else { + machine.Machine = new(command.Machine) + } + + if req.UserName != "" { + machine.Machine.Username = req.UserName + } + if req.Name != "" { + machine.Machine.Name = req.Name + } + + if defaultMachine.Pat != nil || req.PersonalAccessToken != nil { + pat := command.AddPat{ + // Scopes are currently static and can not be overwritten + Scopes: []string{oidc.ScopeOpenID, oidc.ScopeProfile, z_oidc.ScopeUserMetaData, z_oidc.ScopeResourceOwner}, + } + if req.GetPersonalAccessToken().GetExpirationDate().IsValid() { + pat.ExpirationDate = req.PersonalAccessToken.ExpirationDate.AsTime() + } else if defaultMachine.Pat != nil && !defaultMachine.Pat.ExpirationDate.IsZero() { + pat.ExpirationDate = defaultMachine.Pat.ExpirationDate + } + machine.Pat = &pat + } + + if defaultMachine.MachineKey != nil || req.MachineKey != nil { + machineKey := command.AddMachineKey{} + if defaultMachine.MachineKey != nil { + machineKey = *defaultMachine.MachineKey + } + if req.MachineKey != nil { + if req.MachineKey.Type != 0 { + machineKey.Type = authn.KeyTypeToDomain(req.MachineKey.Type) + } + if req.MachineKey.ExpirationDate.IsValid() { + machineKey.ExpirationDate = req.MachineKey.ExpirationDate.AsTime() + } + } + machine.MachineKey = &machineKey + } + + return machine +} diff --git a/internal/api/grpc/instance/v2/instance.go b/internal/api/grpc/instance/v2/instance.go index 2cfb31242d0..9ddf27d5a61 100644 --- a/internal/api/grpc/instance/v2/instance.go +++ b/internal/api/grpc/instance/v2/instance.go @@ -9,6 +9,28 @@ import ( "github.com/zitadel/zitadel/pkg/grpc/instance/v2" ) +func (s *Server) CreateInstance(ctx context.Context, req *instance.CreateInstanceRequest) (*instance.CreateInstanceResponse, error) { + id, pat, key, details, err := s.command.SetUpInstance(ctx, CreateInstancePbToSetupInstance(req, s.defaultInstance, s.externalDomain)) + if err != nil { + return nil, err + } + + var machineKey []byte + if key != nil { + machineKey, err = key.Detail() + if err != nil { + return nil, err + } + } + + return &instance.CreateInstanceResponse{ + Pat: pat, + MachineKey: machineKey, + InstanceId: id, + Details: object.DomainToDetailsPb(details), + }, nil +} + func (s *Server) DeleteInstance(ctx context.Context, request *instance.DeleteInstanceRequest) (*instance.DeleteInstanceResponse, error) { instanceID := strings.TrimSpace(request.GetInstanceId()) if err := validateParam(instanceID, "instance_id"); err != nil { diff --git a/internal/api/grpc/instance/v2/server.go b/internal/api/grpc/instance/v2/server.go index 198dd2cf4b3..3d77f47bc3d 100644 --- a/internal/api/grpc/instance/v2/server.go +++ b/internal/api/grpc/instance/v2/server.go @@ -20,6 +20,8 @@ type Server struct { query *query.Queries checkPermission domain.PermissionCheck systemDefaults systemdefaults.SystemDefaults + defaultInstance command.InstanceSetup + externalDomain string } type Config struct{} @@ -29,12 +31,16 @@ func CreateServer( query *query.Queries, checkPermission domain.PermissionCheck, systemDefaults systemdefaults.SystemDefaults, + defaultInstance command.InstanceSetup, + externalDomain string, ) *Server { return &Server{ command: command, query: query, checkPermission: checkPermission, systemDefaults: systemDefaults, + defaultInstance: defaultInstance, + externalDomain: externalDomain, } } diff --git a/proto/zitadel/authn/v2beta/authn.proto b/proto/zitadel/authn/v2beta/authn.proto new file mode 100644 index 00000000000..aae56eab038 --- /dev/null +++ b/proto/zitadel/authn/v2beta/authn.proto @@ -0,0 +1,35 @@ +syntax = "proto3"; + +import "zitadel/object/v2/object.proto"; +import "google/protobuf/timestamp.proto"; +import "protoc-gen-openapiv2/options/annotations.proto"; + +package zitadel.authn.v2beta; + +option go_package ="github.com/zitadel/zitadel/pkg/grpc/authn/v2beta;authn"; + +message Key { + string id = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"69629023906488334\""; + } + ]; + zitadel.object.v2.Details details = 2; + KeyType type = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"KEY_TYPE_JSON\""; + description: "the file type of the key"; + } + ]; + google.protobuf.Timestamp expiration_date = 4 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "the date a key will expire"; + example: "\"3019-04-01T08:45:00.000000Z\""; + } + ]; +} + +enum KeyType { + KEY_TYPE_UNSPECIFIED = 0; + KEY_TYPE_JSON = 1; +} \ No newline at end of file diff --git a/proto/zitadel/instance/v2/instance_service.proto b/proto/zitadel/instance/v2/instance_service.proto index c36ebe93e44..700e8cccb66 100644 --- a/proto/zitadel/instance/v2/instance_service.proto +++ b/proto/zitadel/instance/v2/instance_service.proto @@ -6,10 +6,12 @@ import "validate/validate.proto"; import "zitadel/object/v2/object.proto"; import "zitadel/instance/v2/instance.proto"; import "zitadel/filter/v2beta/filter.proto"; -import "google/protobuf/empty.proto"; +import "zitadel/authn/v2beta/authn.proto"; import "protoc-gen-openapiv2/options/annotations.proto"; +import "google/protobuf/empty.proto"; import "google/api/annotations.proto"; import "google/api/field_behavior.proto"; +import "google/protobuf/timestamp.proto"; import "zitadel/protoc_gen_zitadel/v2/options.proto"; option go_package = "github.com/zitadel/zitadel/pkg/grpc/instance/v2;instance"; @@ -102,6 +104,72 @@ option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = { } }; +message CreateInstanceRequest { + message Profile { + string first_name = 1 [(validate.rules).string = {max_len: 200}]; + string last_name = 2 [(validate.rules).string = {max_len: 200}]; + string preferred_language = 3 [(validate.rules).string = {max_len: 10}]; + } + message Email { + string email = 1[(validate.rules).string = {min_len: 1, max_len: 200, email: true}]; + bool is_email_verified = 2; + } + message Password { + string password = 1 [(validate.rules).string = {max_len: 200}]; + bool password_change_required = 2; + } + message Human { + string user_name = 1 [(validate.rules).string = {max_len: 200}]; + Email email = 2 [(validate.rules).message.required = true]; + Profile profile = 3 [(validate.rules).message.required = false]; + Password password = 4 [(validate.rules).message.required = false]; + } + message PersonalAccessToken { + google.protobuf.Timestamp expiration_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2519-04-01T08:45:00.000000Z\""; + description: "The date the token will expire and no logins will be possible"; + } + ]; + } + message MachineKey { + zitadel.authn.v2beta.KeyType type = 1 [(validate.rules).enum = {defined_only: true, not_in: [0]}]; + google.protobuf.Timestamp expiration_date = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2519-04-01T08:45:00.000000Z\""; + description: "The date the key will expire and no logins will be possible"; + } + ]; + } + message Machine { + string user_name = 1 [(validate.rules).string = {max_len: 200}]; + string name = 2 [(validate.rules).string = {max_len: 200}]; + PersonalAccessToken personal_access_token = 3; + MachineKey machine_key = 4; + } + + string instance_name = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; + string first_org_name = 2 [(validate.rules).string = {max_len: 200}]; + string custom_domain = 3 [(validate.rules).string = {max_len: 200}]; + + oneof owner { + option (validate.required) = true; + + // oneof field for the user managing the instance + Human human = 4; + Machine machine = 5; + } + + string default_language = 6 [(validate.rules).string = {max_len: 10}]; +} + +message CreateInstanceResponse { + string instance_id = 1; + zitadel.object.v2.Details details = 2; + string pat = 3; + bytes machine_key = 4; +} + message DeleteInstanceRequest { string instance_id = 1 [ (validate.rules).string = {min_len: 1, max_len: 200}, @@ -164,6 +232,36 @@ message ListInstancesResponse { service InstanceService { + // CreateInstance creates a new instance + rpc CreateInstance(CreateInstanceRequest) returns (CreateInstanceResponse) { + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + description: "Creates a new instance"; + tags: "Instance"; + responses: { + key: "200"; + value: { + description: "The created instance."; + schema: { + json_schema: { + ref: "#/definitions/CreateInstanceResponse"; + } + }; + } + }; + }; + + option (google.api.http) = { + post: "/v2/instances" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "system.instance.write" + } + }; + } + // DeleteInstance deletes an instance with the given ID. rpc DeleteInstance(DeleteInstanceRequest) returns (DeleteInstanceResponse) { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {