diff --git a/cmd/defaults.yaml b/cmd/defaults.yaml index c28de428cf..e310ebbeab 100644 --- a/cmd/defaults.yaml +++ b/cmd/defaults.yaml @@ -982,6 +982,8 @@ InternalAuthZ: - "events.read" - "milestones.read" - "session.delete" + - "execution.target.write" + - "execution.target.delete" - Role: "IAM_OWNER_VIEWER" Permissions: - "iam.read" diff --git a/cmd/start/start.go b/cmd/start/start.go index f7dcdc7c27..163d1de2f2 100644 --- a/cmd/start/start.go +++ b/cmd/start/start.go @@ -36,6 +36,7 @@ import ( internal_authz "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/grpc/admin" "github.com/zitadel/zitadel/internal/api/grpc/auth" + execution_v3_alpha "github.com/zitadel/zitadel/internal/api/grpc/execution/v3alpha" "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" @@ -400,6 +401,9 @@ func startAPIs( if err := apis.RegisterService(ctx, org.CreateServer(commands, queries, permissionCheck)); err != nil { return err } + if err := apis.RegisterService(ctx, execution_v3_alpha.CreateServer(commands, queries)); 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/docs/docusaurus.config.js b/docs/docusaurus.config.js index c7afa960ce..ce54fe9ad2 100644 --- a/docs/docusaurus.config.js +++ b/docs/docusaurus.config.js @@ -289,6 +289,13 @@ module.exports = { sidebarOptions: { groupPathsBy: "tag", }, + }, + execution_v3: { + specPath: ".artifacts/openapi/zitadel/execution/v3alpha/execution_service.swagger.json", + outputDir: "docs/apis/resources/execution_service_v3", + sidebarOptions: { + groupPathsBy: "tag", + }, } } }, diff --git a/docs/sidebars.js b/docs/sidebars.js index da50f8bc7b..65cf221ede 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -631,6 +631,20 @@ module.exports = { }, items: require("./docs/apis/resources/settings_service/sidebar.js"), }, + { + type: "category", + label: "Execution Lifecycle (Alpha)", + link: { + type: "generated-index", + title: "Execution Service API (Alpha)", + slug: "/apis/resources/execution_service_v3", + description: + "This API is intended to manage custom executions (previously known as actions) in a ZITADEL instance.\n"+ + "\n"+ + "This project is in alpha state. It can AND will continue breaking until the services provide the same functionality as the current actions.", + }, + items: require("./docs/apis/resources/execution_service_v3/sidebar.js"), + }, { type: "category", label: "Assets", diff --git a/internal/api/grpc/execution/v3alpha/server.go b/internal/api/grpc/execution/v3alpha/server.go new file mode 100644 index 0000000000..aa72102486 --- /dev/null +++ b/internal/api/grpc/execution/v3alpha/server.go @@ -0,0 +1,51 @@ +package execution + +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/query" + execution "github.com/zitadel/zitadel/pkg/grpc/execution/v3alpha" +) + +var _ execution.ExecutionServiceServer = (*Server)(nil) + +type Server struct { + execution.UnimplementedExecutionServiceServer + command *command.Commands + query *query.Queries +} + +type Config struct{} + +func CreateServer( + command *command.Commands, + query *query.Queries, +) *Server { + return &Server{ + command: command, + query: query, + } +} + +func (s *Server) RegisterServer(grpcServer *grpc.Server) { + execution.RegisterExecutionServiceServer(grpcServer, s) +} + +func (s *Server) AppName() string { + return execution.ExecutionService_ServiceDesc.ServiceName +} + +func (s *Server) MethodPrefix() string { + return execution.ExecutionService_ServiceDesc.ServiceName +} + +func (s *Server) AuthMethods() authz.MethodMapping { + return execution.ExecutionService_AuthMethods +} + +func (s *Server) RegisterGateway() server.RegisterGatewayFunc { + return execution.RegisterExecutionServiceHandler +} diff --git a/internal/api/grpc/execution/v3alpha/target.go b/internal/api/grpc/execution/v3alpha/target.go new file mode 100644 index 0000000000..23e23dd5f8 --- /dev/null +++ b/internal/api/grpc/execution/v3alpha/target.go @@ -0,0 +1,95 @@ +package execution + +import ( + "context" + + "github.com/muhlemmer/gu" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/api/grpc/object/v2" + "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore/v1/models" + execution "github.com/zitadel/zitadel/pkg/grpc/execution/v3alpha" +) + +func (s *Server) CreateTarget(ctx context.Context, req *execution.CreateTargetRequest) (*execution.CreateTargetResponse, error) { + add := createTargetToCommand(req) + details, err := s.command.AddTarget(ctx, add, authz.GetInstance(ctx).InstanceID()) + if err != nil { + return nil, err + } + return &execution.CreateTargetResponse{ + Id: add.AggregateID, + Details: object.DomainToDetailsPb(details), + }, nil +} + +func (s *Server) UpdateTarget(ctx context.Context, req *execution.UpdateTargetRequest) (*execution.UpdateTargetResponse, error) { + details, err := s.command.ChangeTarget(ctx, updateTargetToCommand(req), authz.GetInstance(ctx).InstanceID()) + if err != nil { + return nil, err + } + return &execution.UpdateTargetResponse{ + Details: object.DomainToDetailsPb(details), + }, nil +} + +func (s *Server) DeleteTarget(ctx context.Context, req *execution.DeleteTargetRequest) (*execution.DeleteTargetResponse, error) { + details, err := s.command.DeleteTarget(ctx, req.GetTargetId(), authz.GetInstance(ctx).InstanceID()) + if err != nil { + return nil, err + } + return &execution.DeleteTargetResponse{ + Details: object.DomainToDetailsPb(details), + }, nil +} + +func createTargetToCommand(req *execution.CreateTargetRequest) *command.AddTarget { + var targetType domain.TargetType + var url string + switch t := req.GetTargetType().(type) { + case *execution.CreateTargetRequest_RestWebhook: + targetType = domain.TargetTypeWebhook + url = t.RestWebhook.GetUrl() + case *execution.CreateTargetRequest_RestRequestResponse: + targetType = domain.TargetTypeRequestResponse + url = t.RestRequestResponse.GetUrl() + } + return &command.AddTarget{ + Name: req.GetName(), + TargetType: targetType, + URL: url, + Timeout: req.GetTimeout().AsDuration(), + Async: req.GetIsAsync(), + InterruptOnError: req.GetInterruptOnError(), + } +} + +func updateTargetToCommand(req *execution.UpdateTargetRequest) *command.ChangeTarget { + if req == nil { + return nil + } + target := &command.ChangeTarget{ + ObjectRoot: models.ObjectRoot{ + AggregateID: req.GetTargetId(), + }, + Name: req.Name, + } + switch t := req.GetTargetType().(type) { + case *execution.UpdateTargetRequest_RestWebhook: + target.TargetType = gu.Ptr(domain.TargetTypeWebhook) + target.URL = gu.Ptr(t.RestWebhook.GetUrl()) + case *execution.UpdateTargetRequest_RestRequestResponse: + target.TargetType = gu.Ptr(domain.TargetTypeRequestResponse) + target.URL = gu.Ptr(t.RestRequestResponse.GetUrl()) + } + if req.Timeout != nil { + target.Timeout = gu.Ptr(req.GetTimeout().AsDuration()) + } + if req.ExecutionType != nil { + target.Async = gu.Ptr(req.GetIsAsync()) + target.InterruptOnError = gu.Ptr(req.GetInterruptOnError()) + } + return target +} diff --git a/internal/api/grpc/execution/v3alpha/target_integration_test.go b/internal/api/grpc/execution/v3alpha/target_integration_test.go new file mode 100644 index 0000000000..f3f06fab18 --- /dev/null +++ b/internal/api/grpc/execution/v3alpha/target_integration_test.go @@ -0,0 +1,411 @@ +//go:build integration + +package execution_test + +import ( + "context" + "fmt" + "os" + "testing" + "time" + + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/durationpb" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/integration" + execution "github.com/zitadel/zitadel/pkg/grpc/execution/v3alpha" + object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta" +) + +var ( + CTX context.Context + Tester *integration.Tester + Client execution.ExecutionServiceClient +) + +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.ExecutionV3 + + CTX, _ = Tester.WithAuthorization(ctx, integration.IAMOwner), errCtx + return m.Run() + }()) +} + +func TestServer_CreateTarget(t *testing.T) { + tests := []struct { + name string + ctx context.Context + req *execution.CreateTargetRequest + want *execution.CreateTargetResponse + wantErr bool + }{ + { + name: "missing permission", + ctx: Tester.WithAuthorization(context.Background(), integration.OrgOwner), + req: &execution.CreateTargetRequest{ + Name: fmt.Sprint(time.Now().UnixNano() + 1), + }, + wantErr: true, + }, + { + name: "empty name", + ctx: CTX, + req: &execution.CreateTargetRequest{ + Name: "", + }, + wantErr: true, + }, + { + name: "empty type", + ctx: CTX, + req: &execution.CreateTargetRequest{ + Name: fmt.Sprint(time.Now().UnixNano() + 1), + TargetType: nil, + }, + wantErr: true, + }, + { + name: "empty webhook url", + ctx: CTX, + req: &execution.CreateTargetRequest{ + Name: fmt.Sprint(time.Now().UnixNano() + 1), + TargetType: &execution.CreateTargetRequest_RestWebhook{ + RestWebhook: &execution.SetRESTWebhook{}, + }, + }, + wantErr: true, + }, + { + name: "empty request response url", + ctx: CTX, + req: &execution.CreateTargetRequest{ + Name: fmt.Sprint(time.Now().UnixNano() + 1), + TargetType: &execution.CreateTargetRequest_RestRequestResponse{ + RestRequestResponse: &execution.SetRESTRequestResponse{}, + }, + }, + wantErr: true, + }, + { + name: "empty timeout", + ctx: CTX, + req: &execution.CreateTargetRequest{ + Name: fmt.Sprint(time.Now().UnixNano() + 1), + TargetType: &execution.CreateTargetRequest_RestWebhook{ + RestWebhook: &execution.SetRESTWebhook{ + Url: "https://example.com", + }, + }, + Timeout: nil, + ExecutionType: nil, + }, + wantErr: true, + }, + { + name: "empty execution type, ok", + ctx: CTX, + req: &execution.CreateTargetRequest{ + Name: fmt.Sprint(time.Now().UnixNano() + 1), + TargetType: &execution.CreateTargetRequest_RestWebhook{ + RestWebhook: &execution.SetRESTWebhook{ + Url: "https://example.com", + }, + }, + Timeout: durationpb.New(10 * time.Second), + ExecutionType: nil, + }, + want: &execution.CreateTargetResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Instance.InstanceID(), + }, + }, + }, + { + name: "async execution, ok", + ctx: CTX, + req: &execution.CreateTargetRequest{ + Name: fmt.Sprint(time.Now().UnixNano() + 1), + TargetType: &execution.CreateTargetRequest_RestWebhook{ + RestWebhook: &execution.SetRESTWebhook{ + Url: "https://example.com", + }, + }, + Timeout: durationpb.New(10 * time.Second), + ExecutionType: &execution.CreateTargetRequest_IsAsync{ + IsAsync: true, + }, + }, + want: &execution.CreateTargetResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Instance.InstanceID(), + }, + }, + }, + { + name: "interrupt on error execution, ok", + ctx: CTX, + req: &execution.CreateTargetRequest{ + Name: fmt.Sprint(time.Now().UnixNano() + 1), + TargetType: &execution.CreateTargetRequest_RestWebhook{ + RestWebhook: &execution.SetRESTWebhook{ + Url: "https://example.com", + }, + }, + Timeout: durationpb.New(10 * time.Second), + ExecutionType: &execution.CreateTargetRequest_InterruptOnError{ + InterruptOnError: true, + }, + }, + want: &execution.CreateTargetResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Instance.InstanceID(), + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Client.CreateTarget(tt.ctx, tt.req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + + integration.AssertDetails(t, tt.want, got) + assert.NotEmpty(t, got.GetId()) + }) + } +} + +func TestServer_UpdateTarget(t *testing.T) { + type args struct { + ctx context.Context + req *execution.UpdateTargetRequest + } + tests := []struct { + name string + prepare func(request *execution.UpdateTargetRequest) error + args args + want *execution.UpdateTargetResponse + wantErr bool + }{ + { + name: "missing permission", + prepare: func(request *execution.UpdateTargetRequest) error { + targetID := Tester.CreateTarget(CTX, t).GetId() + request.TargetId = targetID + return nil + }, + args: args{ + ctx: Tester.WithAuthorization(context.Background(), integration.OrgOwner), + req: &execution.UpdateTargetRequest{ + Name: gu.Ptr(fmt.Sprint(time.Now().UnixNano() + 1)), + }, + }, + wantErr: true, + }, + { + name: "not existing", + prepare: func(request *execution.UpdateTargetRequest) error { + request.TargetId = "notexisting" + return nil + }, + args: args{ + ctx: CTX, + req: &execution.UpdateTargetRequest{ + Name: gu.Ptr(fmt.Sprint(time.Now().UnixNano() + 1)), + }, + }, + wantErr: true, + }, + { + name: "change name, ok", + prepare: func(request *execution.UpdateTargetRequest) error { + targetID := Tester.CreateTarget(CTX, t).GetId() + request.TargetId = targetID + return nil + }, + args: args{ + ctx: CTX, + req: &execution.UpdateTargetRequest{ + Name: gu.Ptr(fmt.Sprint(time.Now().UnixNano() + 1)), + }, + }, + want: &execution.UpdateTargetResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Instance.InstanceID(), + }, + }, + }, + { + name: "change type, ok", + prepare: func(request *execution.UpdateTargetRequest) error { + targetID := Tester.CreateTarget(CTX, t).GetId() + request.TargetId = targetID + return nil + }, + args: args{ + ctx: CTX, + req: &execution.UpdateTargetRequest{ + TargetType: &execution.UpdateTargetRequest_RestRequestResponse{ + RestRequestResponse: &execution.SetRESTRequestResponse{ + Url: "https://example.com", + }, + }, + }, + }, + want: &execution.UpdateTargetResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Instance.InstanceID(), + }, + }, + }, + { + name: "change url, ok", + prepare: func(request *execution.UpdateTargetRequest) error { + targetID := Tester.CreateTarget(CTX, t).GetId() + request.TargetId = targetID + return nil + }, + args: args{ + ctx: CTX, + req: &execution.UpdateTargetRequest{ + TargetType: &execution.UpdateTargetRequest_RestWebhook{ + RestWebhook: &execution.SetRESTWebhook{ + Url: "https://example.com/hooks/new", + }, + }, + }, + }, + want: &execution.UpdateTargetResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Instance.InstanceID(), + }, + }, + }, + { + name: "change timeout, ok", + prepare: func(request *execution.UpdateTargetRequest) error { + targetID := Tester.CreateTarget(CTX, t).GetId() + request.TargetId = targetID + return nil + }, + args: args{ + ctx: CTX, + req: &execution.UpdateTargetRequest{ + Timeout: durationpb.New(20 * time.Second), + }, + }, + want: &execution.UpdateTargetResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Instance.InstanceID(), + }, + }, + }, + { + name: "change execution type, ok", + prepare: func(request *execution.UpdateTargetRequest) error { + targetID := Tester.CreateTarget(CTX, t).GetId() + request.TargetId = targetID + return nil + }, + args: args{ + ctx: CTX, + req: &execution.UpdateTargetRequest{ + ExecutionType: &execution.UpdateTargetRequest_IsAsync{ + IsAsync: true, + }, + }, + }, + want: &execution.UpdateTargetResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Instance.InstanceID(), + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.prepare(tt.args.req) + require.NoError(t, err) + + got, err := Client.UpdateTarget(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + integration.AssertDetails(t, tt.want, got) + }) + } +} + +func TestServer_DeleteTarget(t *testing.T) { + target := Tester.CreateTarget(CTX, t) + tests := []struct { + name string + ctx context.Context + req *execution.DeleteTargetRequest + want *execution.DeleteTargetResponse + wantErr bool + }{ + { + name: "missing permission", + ctx: Tester.WithAuthorization(context.Background(), integration.OrgOwner), + req: &execution.DeleteTargetRequest{ + TargetId: target.GetId(), + }, + wantErr: true, + }, + { + name: "empty id", + ctx: CTX, + req: &execution.DeleteTargetRequest{ + TargetId: "", + }, + wantErr: true, + }, + { + name: "delete target", + ctx: CTX, + req: &execution.DeleteTargetRequest{ + TargetId: target.GetId(), + }, + want: &execution.DeleteTargetResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Instance.InstanceID(), + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Client.DeleteTarget(tt.ctx, tt.req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + integration.AssertDetails(t, tt.want, got) + }) + } +} diff --git a/internal/api/grpc/execution/v3alpha/target_test.go b/internal/api/grpc/execution/v3alpha/target_test.go new file mode 100644 index 0000000000..2d266f10ed --- /dev/null +++ b/internal/api/grpc/execution/v3alpha/target_test.go @@ -0,0 +1,193 @@ +package execution + +import ( + "testing" + "time" + + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + "google.golang.org/protobuf/types/known/durationpb" + + "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/domain" + execution "github.com/zitadel/zitadel/pkg/grpc/execution/v3alpha" +) + +func Test_createTargetToCommand(t *testing.T) { + type args struct { + req *execution.CreateTargetRequest + } + tests := []struct { + name string + args args + want *command.AddTarget + }{ + { + name: "nil", + args: args{nil}, + want: &command.AddTarget{ + Name: "", + TargetType: domain.TargetTypeUnspecified, + URL: "", + Timeout: 0, + Async: false, + InterruptOnError: false, + }, + }, + { + name: "all fields (async webhook)", + args: args{&execution.CreateTargetRequest{ + Name: "target 1", + TargetType: &execution.CreateTargetRequest_RestWebhook{ + RestWebhook: &execution.SetRESTWebhook{ + Url: "https://example.com/hooks/1", + }, + }, + Timeout: durationpb.New(10 * time.Second), + ExecutionType: &execution.CreateTargetRequest_IsAsync{ + IsAsync: true, + }, + }}, + want: &command.AddTarget{ + Name: "target 1", + TargetType: domain.TargetTypeWebhook, + URL: "https://example.com/hooks/1", + Timeout: 10 * time.Second, + Async: true, + InterruptOnError: false, + }, + }, + { + name: "all fields (interrupting response)", + args: args{&execution.CreateTargetRequest{ + Name: "target 1", + TargetType: &execution.CreateTargetRequest_RestRequestResponse{ + RestRequestResponse: &execution.SetRESTRequestResponse{ + Url: "https://example.com/hooks/1", + }, + }, + Timeout: durationpb.New(10 * time.Second), + ExecutionType: &execution.CreateTargetRequest_InterruptOnError{ + InterruptOnError: true, + }, + }}, + want: &command.AddTarget{ + Name: "target 1", + TargetType: domain.TargetTypeRequestResponse, + URL: "https://example.com/hooks/1", + Timeout: 10 * time.Second, + Async: false, + InterruptOnError: true, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := createTargetToCommand(tt.args.req) + assert.Equal(t, tt.want, got) + }) + } +} + +func Test_updateTargetToCommand(t *testing.T) { + type args struct { + req *execution.UpdateTargetRequest + } + tests := []struct { + name string + args args + want *command.ChangeTarget + }{ + { + name: "nil", + args: args{nil}, + want: nil, + }, + { + name: "all fields nil", + args: args{&execution.UpdateTargetRequest{ + Name: nil, + TargetType: nil, + Timeout: nil, + ExecutionType: nil, + }}, + want: &command.ChangeTarget{ + Name: nil, + TargetType: nil, + URL: nil, + Timeout: nil, + Async: nil, + InterruptOnError: nil, + }, + }, + { + name: "all fields empty", + args: args{&execution.UpdateTargetRequest{ + Name: gu.Ptr(""), + TargetType: nil, + Timeout: durationpb.New(0), + ExecutionType: nil, + }}, + want: &command.ChangeTarget{ + Name: gu.Ptr(""), + TargetType: nil, + URL: nil, + Timeout: gu.Ptr(0 * time.Second), + Async: nil, + InterruptOnError: nil, + }, + }, + { + name: "all fields (async webhook)", + args: args{&execution.UpdateTargetRequest{ + Name: gu.Ptr("target 1"), + TargetType: &execution.UpdateTargetRequest_RestWebhook{ + RestWebhook: &execution.SetRESTWebhook{ + Url: "https://example.com/hooks/1", + }, + }, + Timeout: durationpb.New(10 * time.Second), + ExecutionType: &execution.UpdateTargetRequest_IsAsync{ + IsAsync: true, + }, + }}, + want: &command.ChangeTarget{ + Name: gu.Ptr("target 1"), + TargetType: gu.Ptr(domain.TargetTypeWebhook), + URL: gu.Ptr("https://example.com/hooks/1"), + Timeout: gu.Ptr(10 * time.Second), + Async: gu.Ptr(true), + InterruptOnError: gu.Ptr(false), + }, + }, + { + name: "all fields (interrupting response)", + args: args{&execution.UpdateTargetRequest{ + Name: gu.Ptr("target 1"), + TargetType: &execution.UpdateTargetRequest_RestRequestResponse{ + RestRequestResponse: &execution.SetRESTRequestResponse{ + Url: "https://example.com/hooks/1", + }, + }, + Timeout: durationpb.New(10 * time.Second), + ExecutionType: &execution.UpdateTargetRequest_InterruptOnError{ + InterruptOnError: true, + }, + }}, + want: &command.ChangeTarget{ + Name: gu.Ptr("target 1"), + TargetType: gu.Ptr(domain.TargetTypeRequestResponse), + URL: gu.Ptr("https://example.com/hooks/1"), + Timeout: gu.Ptr(10 * time.Second), + Async: gu.Ptr(false), + InterruptOnError: gu.Ptr(true), + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := updateTargetToCommand(tt.args.req) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/internal/command/action_v2_target.go b/internal/command/action_v2_target.go new file mode 100644 index 0000000000..3ed2f574ae --- /dev/null +++ b/internal/command/action_v2_target.go @@ -0,0 +1,177 @@ +package command + +import ( + "context" + "net/url" + "time" + + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore/v1/models" + "github.com/zitadel/zitadel/internal/repository/target" + "github.com/zitadel/zitadel/internal/zerrors" +) + +type AddTarget struct { + models.ObjectRoot + + Name string + TargetType domain.TargetType + URL string + Timeout time.Duration + Async bool + InterruptOnError bool +} + +func (a *AddTarget) IsValid() error { + if a.Name == "" { + return zerrors.ThrowInvalidArgument(nil, "COMMAND-ddqbm9us5p", "Errors.Target.Invalid") + } + if a.Timeout == 0 { + return zerrors.ThrowInvalidArgument(nil, "COMMAND-39f35d8uri", "Errors.Target.NoTimeout") + } + _, err := url.Parse(a.URL) + if err != nil || a.URL == "" { + return zerrors.ThrowInvalidArgument(nil, "COMMAND-1r2k6qo6wg", "Errors.Target.InvalidURL") + } + + return nil +} + +func (c *Commands) AddTarget(ctx context.Context, add *AddTarget, resourceOwner string) (_ *domain.ObjectDetails, err error) { + if resourceOwner == "" { + return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-brml926e2d", "Errors.IDMissing") + } + + if err := add.IsValid(); err != nil { + return nil, err + } + + if add.AggregateID == "" { + add.AggregateID, err = c.idGenerator.Next() + if err != nil { + return nil, err + } + } + + wm := NewTargetWriteModel(add.AggregateID, resourceOwner) + pushedEvents, err := c.eventstore.Push(ctx, target.NewAddedEvent( + ctx, + TargetAggregateFromWriteModel(&wm.WriteModel), + add.Name, + add.TargetType, + add.URL, + add.Timeout, + add.Async, + add.InterruptOnError, + )) + if err != nil { + return nil, err + } + if err := AppendAndReduce(wm, pushedEvents...); err != nil { + return nil, err + } + return writeModelToObjectDetails(&wm.WriteModel), nil +} + +type ChangeTarget struct { + models.ObjectRoot + + Name *string + TargetType *domain.TargetType + URL *string + Timeout *time.Duration + Async *bool + InterruptOnError *bool +} + +func (a *ChangeTarget) IsValid() error { + if a.AggregateID == "" { + return zerrors.ThrowInvalidArgument(nil, "COMMAND-1l6ympeagp", "Errors.IDMissing") + } + if a.Name != nil && *a.Name == "" { + return zerrors.ThrowInvalidArgument(nil, "COMMAND-d1wx4lm0zr", "Errors.Target.Invalid") + } + if a.Timeout != nil && *a.Timeout == 0 { + return zerrors.ThrowInvalidArgument(nil, "COMMAND-08b39vdi57", "Errors.Target.NoTimeout") + } + if a.URL != nil { + _, err := url.Parse(*a.URL) + if err != nil || *a.URL == "" { + return zerrors.ThrowInvalidArgument(nil, "COMMAND-jsbaera7b6", "Errors.Target.InvalidURL") + } + } + return nil +} + +func (c *Commands) ChangeTarget(ctx context.Context, change *ChangeTarget, resourceOwner string) (*domain.ObjectDetails, error) { + if resourceOwner == "" { + return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-zqibgg0wwh", "Errors.IDMissing") + } + if err := change.IsValid(); err != nil { + return nil, err + } + + existing, err := c.getTargetWriteModelByID(ctx, change.AggregateID, resourceOwner) + if err != nil { + return nil, err + } + if !existing.State.Exists() { + return nil, zerrors.ThrowNotFound(nil, "COMMAND-xj14f2cccn", "Errors.Target.NotFound") + } + + changedEvent := existing.NewChangedEvent( + ctx, + TargetAggregateFromWriteModel(&existing.WriteModel), + change.Name, + change.TargetType, + change.URL, + change.Timeout, + change.Async, + change.InterruptOnError) + if changedEvent == nil { + return writeModelToObjectDetails(&existing.WriteModel), nil + } + pushedEvents, err := c.eventstore.Push(ctx, changedEvent) + if err != nil { + return nil, err + } + err = AppendAndReduce(existing, pushedEvents...) + if err != nil { + return nil, err + } + return writeModelToObjectDetails(&existing.WriteModel), nil +} + +func (c *Commands) DeleteTarget(ctx context.Context, id, resourceOwner string) (*domain.ObjectDetails, error) { + if id == "" || resourceOwner == "" { + return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-obqos2l3no", "Errors.IDMissing") + } + + existing, err := c.getTargetWriteModelByID(ctx, id, resourceOwner) + if err != nil { + return nil, err + } + if !existing.State.Exists() { + return nil, zerrors.ThrowNotFound(nil, "COMMAND-k4s7ucu0ax", "Errors.Target.NotFound") + } + + if err := c.pushAppendAndReduce(ctx, + existing, + target.NewRemovedEvent(ctx, + TargetAggregateFromWriteModel(&existing.WriteModel), + existing.Name, + ), + ); err != nil { + return nil, err + } + return writeModelToObjectDetails(&existing.WriteModel), nil +} + +func (c *Commands) getTargetWriteModelByID(ctx context.Context, id string, resourceOwner string) (*TargetWriteModel, error) { + wm := NewTargetWriteModel(id, resourceOwner) + err := c.eventstore.FilterToQueryReducer(ctx, wm) + if err != nil { + return nil, err + } + return wm, nil +} diff --git a/internal/command/action_v2_target_model.go b/internal/command/action_v2_target_model.go new file mode 100644 index 0000000000..f480f28528 --- /dev/null +++ b/internal/command/action_v2_target_model.go @@ -0,0 +1,127 @@ +package command + +import ( + "context" + "time" + + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/action" + "github.com/zitadel/zitadel/internal/repository/target" +) + +type TargetWriteModel struct { + eventstore.WriteModel + + Name string + TargetType domain.TargetType + URL string + Timeout time.Duration + Async bool + InterruptOnError bool + + State domain.TargetState +} + +func NewTargetWriteModel(id string, resourceOwner string) *TargetWriteModel { + return &TargetWriteModel{ + WriteModel: eventstore.WriteModel{ + AggregateID: id, + ResourceOwner: resourceOwner, + InstanceID: resourceOwner, + }, + } +} + +func (wm *TargetWriteModel) Reduce() error { + for _, event := range wm.Events { + switch e := event.(type) { + case *target.AddedEvent: + wm.Name = e.Name + wm.TargetType = e.TargetType + wm.URL = e.URL + wm.Timeout = e.Timeout + wm.Async = e.Async + wm.State = domain.TargetActive + case *target.ChangedEvent: + if e.Name != nil { + wm.Name = *e.Name + } + if e.TargetType != nil { + wm.TargetType = *e.TargetType + } + if e.URL != nil { + wm.URL = *e.URL + } + if e.Timeout != nil { + wm.Timeout = *e.Timeout + } + if e.Async != nil { + wm.Async = *e.Async + } + if e.InterruptOnError != nil { + wm.InterruptOnError = *e.InterruptOnError + } + case *action.RemovedEvent: + wm.State = domain.TargetRemoved + } + } + return wm.WriteModel.Reduce() +} + +func (wm *TargetWriteModel) Query() *eventstore.SearchQueryBuilder { + return eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). + ResourceOwner(wm.ResourceOwner). + AddQuery(). + AggregateTypes(target.AggregateType). + AggregateIDs(wm.AggregateID). + EventTypes(target.AddedEventType, + target.ChangedEventType, + target.RemovedEventType). + Builder() +} + +func (wm *TargetWriteModel) NewChangedEvent( + ctx context.Context, + agg *eventstore.Aggregate, + name *string, + targetType *domain.TargetType, + url *string, + timeout *time.Duration, + async *bool, + interruptOnError *bool, +) *target.ChangedEvent { + changes := make([]target.Changes, 0) + if name != nil && wm.Name != *name { + changes = append(changes, target.ChangeName(wm.Name, *name)) + } + if targetType != nil && wm.TargetType != *targetType { + changes = append(changes, target.ChangeTargetType(*targetType)) + } + if url != nil && wm.URL != *url { + changes = append(changes, target.ChangeURL(*url)) + } + if timeout != nil && wm.Timeout != *timeout { + changes = append(changes, target.ChangeTimeout(*timeout)) + } + if async != nil && wm.Async != *async { + changes = append(changes, target.ChangeAsync(*async)) + } + if interruptOnError != nil && wm.InterruptOnError != *interruptOnError { + changes = append(changes, target.ChangeInterruptOnError(*interruptOnError)) + } + if len(changes) == 0 { + return nil + } + return target.NewChangedEvent(ctx, agg, changes) +} + +func TargetAggregateFromWriteModel(wm *eventstore.WriteModel) *eventstore.Aggregate { + return &eventstore.Aggregate{ + ID: wm.AggregateID, + Type: target.AggregateType, + ResourceOwner: wm.ResourceOwner, + InstanceID: wm.InstanceID, + Version: target.AggregateVersion, + } +} diff --git a/internal/command/action_v2_target_test.go b/internal/command/action_v2_target_test.go new file mode 100644 index 0000000000..bd49254ae1 --- /dev/null +++ b/internal/command/action_v2_target_test.go @@ -0,0 +1,676 @@ +package command + +import ( + "context" + "testing" + "time" + + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + + "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/target" + "github.com/zitadel/zitadel/internal/zerrors" +) + +func TestCommands_AddTarget(t *testing.T) { + type fields struct { + eventstore *eventstore.Eventstore + idGenerator id.Generator + } + type args struct { + ctx context.Context + add *AddTarget + resourceOwner string + } + type res struct { + id string + details *domain.ObjectDetails + err func(error) bool + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + "no resourceowner, error", + fields{ + eventstore: eventstoreExpect(t), + }, + args{ + ctx: context.Background(), + add: &AddTarget{}, + resourceOwner: "", + }, + res{ + err: zerrors.IsErrorInvalidArgument, + }, + }, + { + "no name, error", + fields{ + eventstore: eventstoreExpect(t), + }, + args{ + ctx: context.Background(), + add: &AddTarget{}, + resourceOwner: "org1", + }, + res{ + err: zerrors.IsErrorInvalidArgument, + }, + }, + { + "no timeout, error", + fields{ + eventstore: eventstoreExpect(t), + }, + args{ + ctx: context.Background(), + add: &AddTarget{ + Name: "name", + }, + resourceOwner: "org1", + }, + res{ + err: zerrors.IsErrorInvalidArgument, + }, + }, + { + "no url, error", + fields{ + eventstore: eventstoreExpect(t), + }, + args{ + ctx: context.Background(), + add: &AddTarget{ + Name: "name", + Timeout: time.Second, + URL: "", + }, + resourceOwner: "org1", + }, + res{ + err: zerrors.IsErrorInvalidArgument, + }, + }, + { + "no parsable url, error", + fields{ + eventstore: eventstoreExpect(t), + }, + args{ + ctx: context.Background(), + add: &AddTarget{ + Name: "name", + Timeout: time.Second, + URL: "://", + }, + resourceOwner: "org1", + }, + res{ + err: zerrors.IsErrorInvalidArgument, + }, + }, + { + "unique constraint failed, error", + fields{ + eventstore: eventstoreExpect(t, + expectPushFailed( + zerrors.ThrowPreconditionFailed(nil, "id", "name already exists"), + target.NewAddedEvent(context.Background(), + target.NewAggregate("id1", "org1"), + "name", + domain.TargetTypeWebhook, + "https://example.com", + time.Second, + false, + false, + ), + ), + ), + idGenerator: mock.ExpectID(t, "id1"), + }, + args{ + ctx: context.Background(), + add: &AddTarget{ + Name: "name", + URL: "https://example.com", + Timeout: time.Second, + TargetType: domain.TargetTypeWebhook, + }, + resourceOwner: "org1", + }, + res{ + err: zerrors.IsPreconditionFailed, + }, + }, + { + "push ok", + fields{ + eventstore: eventstoreExpect(t, + expectPush( + target.NewAddedEvent(context.Background(), + target.NewAggregate("id1", "org1"), + "name", + domain.TargetTypeWebhook, + "https://example.com", + time.Second, + false, + false, + ), + ), + ), + idGenerator: mock.ExpectID(t, "id1"), + }, + args{ + ctx: context.Background(), + add: &AddTarget{ + Name: "name", + TargetType: domain.TargetTypeWebhook, + Timeout: time.Second, + URL: "https://example.com", + }, + resourceOwner: "org1", + }, + res{ + id: "id1", + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + "push full ok", + fields{ + eventstore: eventstoreExpect(t, + expectPush( + target.NewAddedEvent(context.Background(), + target.NewAggregate("id1", "org1"), + "name", + domain.TargetTypeWebhook, + "https://example.com", + time.Second, + true, + true, + ), + ), + ), + idGenerator: mock.ExpectID(t, "id1"), + }, + args{ + ctx: context.Background(), + add: &AddTarget{ + Name: "name", + TargetType: domain.TargetTypeWebhook, + URL: "https://example.com", + Timeout: time.Second, + Async: true, + InterruptOnError: true, + }, + resourceOwner: "org1", + }, + res{ + id: "id1", + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore, + idGenerator: tt.fields.idGenerator, + } + details, err := c.AddTarget(tt.args.ctx, tt.args.add, tt.args.resourceOwner) + if tt.res.err == nil { + assert.NoError(t, err) + } + if tt.res.err != nil && !tt.res.err(err) { + t.Errorf("got wrong err: %v ", err) + } + if tt.res.err == nil { + assert.Equal(t, tt.res.id, tt.args.add.AggregateID) + assert.Equal(t, tt.res.details, details) + } + }) + } +} + +func TestCommands_ChangeTarget(t *testing.T) { + type fields struct { + eventstore *eventstore.Eventstore + } + type args struct { + ctx context.Context + change *ChangeTarget + resourceOwner string + } + type res struct { + details *domain.ObjectDetails + err func(error) bool + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + "resourceowner missing, error", + fields{ + eventstore: eventstoreExpect(t), + }, + args{ + ctx: context.Background(), + change: &ChangeTarget{}, + resourceOwner: "", + }, + res{ + err: zerrors.IsErrorInvalidArgument, + }, + }, + { + "id missing, error", + fields{ + eventstore: eventstoreExpect(t), + }, + args{ + ctx: context.Background(), + change: &ChangeTarget{}, + resourceOwner: "org1", + }, + res{ + err: zerrors.IsErrorInvalidArgument, + }, + }, + { + "name empty, error", + fields{ + eventstore: eventstoreExpect(t), + }, + args{ + ctx: context.Background(), + change: &ChangeTarget{ + Name: gu.Ptr(""), + }, + resourceOwner: "org1", + }, + res{ + err: zerrors.IsErrorInvalidArgument, + }, + }, + { + "timeout empty, error", + fields{ + eventstore: eventstoreExpect(t), + }, + args{ + ctx: context.Background(), + change: &ChangeTarget{ + Timeout: gu.Ptr(time.Duration(0)), + }, + resourceOwner: "org1", + }, + res{ + err: zerrors.IsErrorInvalidArgument, + }, + }, + { + "url empty, error", + fields{ + eventstore: eventstoreExpect(t), + }, + args{ + ctx: context.Background(), + change: &ChangeTarget{ + URL: gu.Ptr(""), + }, + resourceOwner: "org1", + }, + res{ + err: zerrors.IsErrorInvalidArgument, + }, + }, + { + "url not parsable, error", + fields{ + eventstore: eventstoreExpect(t), + }, + args{ + ctx: context.Background(), + change: &ChangeTarget{ + URL: gu.Ptr("://"), + }, + resourceOwner: "org1", + }, + res{ + err: zerrors.IsErrorInvalidArgument, + }, + }, + { + "not found, error", + fields{ + eventstore: eventstoreExpect(t, + expectFilter(), + ), + }, + args{ + ctx: context.Background(), + change: &ChangeTarget{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "id1", + }, + Name: gu.Ptr("name"), + }, + resourceOwner: "org1", + }, + res{ + err: zerrors.IsNotFound, + }, + }, + { + "no changes", + fields{ + eventstore: eventstoreExpect(t, + expectFilter( + eventFromEventPusher( + target.NewAddedEvent(context.Background(), + target.NewAggregate("id1", "org1"), + "name", + domain.TargetTypeWebhook, + "https://example.com", + 0, + false, + false, + ), + ), + ), + ), + }, + args{ + ctx: context.Background(), + change: &ChangeTarget{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "id1", + }, + TargetType: gu.Ptr(domain.TargetTypeWebhook), + }, + resourceOwner: "org1", + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + "unique constraint failed, error", + fields{ + eventstore: eventstoreExpect(t, + expectFilter( + eventFromEventPusher( + target.NewAddedEvent(context.Background(), + target.NewAggregate("id1", "org1"), + "name", + domain.TargetTypeWebhook, + "https://example.com", + 0, + false, + false, + ), + ), + ), + expectPushFailed( + zerrors.ThrowPreconditionFailed(nil, "id", "name already exists"), + target.NewChangedEvent(context.Background(), + target.NewAggregate("id1", "org1"), + []target.Changes{ + target.ChangeName("name", "name2"), + }, + ), + ), + ), + }, + args{ + ctx: context.Background(), + change: &ChangeTarget{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "id1", + }, + Name: gu.Ptr("name2"), + }, + resourceOwner: "org1", + }, + res{ + err: zerrors.IsPreconditionFailed, + }, + }, + { + "push ok", + fields{ + eventstore: eventstoreExpect(t, + expectFilter( + eventFromEventPusher( + target.NewAddedEvent(context.Background(), + target.NewAggregate("id1", "org1"), + "name", + domain.TargetTypeWebhook, + "https://example.com", + 0, + false, + false, + ), + ), + ), + expectPush( + target.NewChangedEvent(context.Background(), + target.NewAggregate("id1", "org1"), + []target.Changes{ + target.ChangeName("name", "name2"), + }, + ), + ), + ), + }, + args{ + ctx: context.Background(), + change: &ChangeTarget{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "id1", + }, + Name: gu.Ptr("name2"), + }, + resourceOwner: "org1", + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + "push full ok", + fields{ + eventstore: eventstoreExpect(t, + expectFilter( + eventFromEventPusher( + target.NewAddedEvent(context.Background(), + target.NewAggregate("id1", "org1"), + "name", + domain.TargetTypeWebhook, + "https://example.com", + 0, + false, + false, + ), + ), + ), + expectPush( + target.NewChangedEvent(context.Background(), + target.NewAggregate("id1", "org1"), + []target.Changes{ + target.ChangeName("name", "name2"), + target.ChangeURL("https://example2.com"), + target.ChangeTargetType(domain.TargetTypeRequestResponse), + target.ChangeTimeout(time.Second), + target.ChangeAsync(true), + target.ChangeInterruptOnError(true), + }, + ), + ), + ), + }, + args{ + ctx: context.Background(), + change: &ChangeTarget{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "id1", + }, + Name: gu.Ptr("name2"), + URL: gu.Ptr("https://example2.com"), + TargetType: gu.Ptr(domain.TargetTypeRequestResponse), + Timeout: gu.Ptr(time.Second), + Async: gu.Ptr(true), + InterruptOnError: gu.Ptr(true), + }, + resourceOwner: "org1", + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore, + } + details, err := c.ChangeTarget(tt.args.ctx, tt.args.change, tt.args.resourceOwner) + if tt.res.err == nil { + assert.NoError(t, err) + } + if tt.res.err != nil && !tt.res.err(err) { + t.Errorf("got wrong err: %v ", err) + } + if tt.res.err == nil { + assert.Equal(t, tt.res.details, details) + } + }) + } +} + +func TestCommands_DeleteTarget(t *testing.T) { + type fields struct { + eventstore *eventstore.Eventstore + } + type args struct { + ctx context.Context + id string + resourceOwner string + } + type res struct { + details *domain.ObjectDetails + err func(error) bool + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + "id missing, error", + fields{ + eventstore: eventstoreExpect(t), + }, + args{ + ctx: context.Background(), + id: "", + resourceOwner: "org1", + }, + res{ + err: zerrors.IsErrorInvalidArgument, + }, + }, + { + "not found, error", + fields{ + eventstore: eventstoreExpect(t, + expectFilter(), + ), + }, + args{ + ctx: context.Background(), + id: "id1", + resourceOwner: "org1", + }, + res{ + err: zerrors.IsNotFound, + }, + }, + { + "remove ok", + fields{ + eventstore: eventstoreExpect(t, + expectFilter( + eventFromEventPusher( + target.NewAddedEvent(context.Background(), + target.NewAggregate("id1", "org1"), + "name", + domain.TargetTypeWebhook, + "https://example.com", + 0, + false, + false, + ), + ), + ), + expectPush( + target.NewRemovedEvent(context.Background(), + target.NewAggregate("id1", "org1"), + "name", + ), + ), + ), + }, + args{ + ctx: context.Background(), + id: "id1", + resourceOwner: "org1", + }, + res{ + details: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore, + } + details, err := c.DeleteTarget(tt.args.ctx, tt.args.id, tt.args.resourceOwner) + if tt.res.err == nil { + assert.NoError(t, err) + } + if tt.res.err != nil && !tt.res.err(err) { + t.Errorf("got wrong err: %v ", err) + } + if tt.res.err == nil { + assert.Equal(t, tt.res.details, details) + } + }) + } +} diff --git a/internal/domain/target.go b/internal/domain/target.go new file mode 100644 index 0000000000..d4a8027395 --- /dev/null +++ b/internal/domain/target.go @@ -0,0 +1,28 @@ +package domain + +type TargetType uint + +const ( + TargetTypeUnspecified TargetType = iota + TargetTypeWebhook + TargetTypeRequestResponse + + TargetTypeStateCount +) + +type TargetState int32 + +const ( + TargetUnspecified TargetState = iota + TargetActive + TargetRemoved + targetStateCount +) + +func (s TargetState) Valid() bool { + return s >= 0 && s < targetStateCount +} + +func (s TargetState) Exists() bool { + return s != TargetUnspecified && s != TargetRemoved +} diff --git a/internal/integration/client.go b/internal/integration/client.go index 5465560b1e..0d53fa6576 100644 --- a/internal/integration/client.go +++ b/internal/integration/client.go @@ -26,6 +26,7 @@ import ( "github.com/zitadel/zitadel/internal/repository/idp" "github.com/zitadel/zitadel/pkg/grpc/admin" "github.com/zitadel/zitadel/pkg/grpc/auth" + execution "github.com/zitadel/zitadel/pkg/grpc/execution/v3alpha" mgmt "github.com/zitadel/zitadel/pkg/grpc/management" object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta" oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2beta" @@ -38,28 +39,30 @@ import ( ) type Client struct { - CC *grpc.ClientConn - Admin admin.AdminServiceClient - Mgmt mgmt.ManagementServiceClient - Auth auth.AuthServiceClient - UserV2 user.UserServiceClient - SessionV2 session.SessionServiceClient - OIDCv2 oidc_pb.OIDCServiceClient - OrgV2 organisation.OrganizationServiceClient - System system.SystemServiceClient + CC *grpc.ClientConn + Admin admin.AdminServiceClient + Mgmt mgmt.ManagementServiceClient + Auth auth.AuthServiceClient + UserV2 user.UserServiceClient + SessionV2 session.SessionServiceClient + OIDCv2 oidc_pb.OIDCServiceClient + OrgV2 organisation.OrganizationServiceClient + System system.SystemServiceClient + ExecutionV3 execution.ExecutionServiceClient } func newClient(cc *grpc.ClientConn) Client { return Client{ - CC: cc, - Admin: admin.NewAdminServiceClient(cc), - Mgmt: mgmt.NewManagementServiceClient(cc), - Auth: auth.NewAuthServiceClient(cc), - UserV2: user.NewUserServiceClient(cc), - SessionV2: session.NewSessionServiceClient(cc), - OIDCv2: oidc_pb.NewOIDCServiceClient(cc), - OrgV2: organisation.NewOrganizationServiceClient(cc), - System: system.NewSystemServiceClient(cc), + CC: cc, + Admin: admin.NewAdminServiceClient(cc), + Mgmt: mgmt.NewManagementServiceClient(cc), + Auth: auth.NewAuthServiceClient(cc), + UserV2: user.NewUserServiceClient(cc), + SessionV2: session.NewSessionServiceClient(cc), + OIDCv2: oidc_pb.NewOIDCServiceClient(cc), + OrgV2: organisation.NewOrganizationServiceClient(cc), + System: system.NewSystemServiceClient(cc), + ExecutionV3: execution.NewExecutionServiceClient(cc), } } @@ -503,3 +506,20 @@ func (s *Tester) CreateProjectMembership(t *testing.T, ctx context.Context, proj }) require.NoError(t, err) } + +func (s *Tester) CreateTarget(ctx context.Context, t *testing.T) *execution.CreateTargetResponse { + target, err := s.Client.ExecutionV3.CreateTarget(ctx, &execution.CreateTargetRequest{ + Name: fmt.Sprint(time.Now().UnixNano() + 1), + TargetType: &execution.CreateTargetRequest_RestWebhook{ + RestWebhook: &execution.SetRESTWebhook{ + Url: "https://example.com", + }, + }, + Timeout: durationpb.New(10 * time.Second), + ExecutionType: &execution.CreateTargetRequest_InterruptOnError{ + InterruptOnError: true, + }, + }) + require.NoError(t, err) + return target +} diff --git a/internal/repository/target/aggregate.go b/internal/repository/target/aggregate.go new file mode 100644 index 0000000000..4fbbe344dd --- /dev/null +++ b/internal/repository/target/aggregate.go @@ -0,0 +1,18 @@ +package target + +import "github.com/zitadel/zitadel/internal/eventstore" + +const ( + AggregateType = "target" + AggregateVersion = "v1" +) + +func NewAggregate(aggrID, instanceID string) *eventstore.Aggregate { + return &eventstore.Aggregate{ + ID: aggrID, + Type: AggregateType, + ResourceOwner: instanceID, + InstanceID: instanceID, + Version: AggregateVersion, + } +} diff --git a/internal/repository/target/constraints.go b/internal/repository/target/constraints.go new file mode 100644 index 0000000000..87cc0a08ae --- /dev/null +++ b/internal/repository/target/constraints.go @@ -0,0 +1,25 @@ +package target + +import ( + "github.com/zitadel/zitadel/internal/eventstore" +) + +const ( + UniqueTarget = "target" + DuplicateTarget = "Errors.Target.AlreadyExists" +) + +func NewAddUniqueConstraint(name string) *eventstore.UniqueConstraint { + return eventstore.NewAddEventUniqueConstraint( + UniqueTarget, + name, + DuplicateTarget, + ) +} + +func NewRemoveUniqueConstraint(name string) *eventstore.UniqueConstraint { + return eventstore.NewRemoveUniqueConstraint( + UniqueTarget, + name, + ) +} diff --git a/internal/repository/target/eventstore.go b/internal/repository/target/eventstore.go new file mode 100644 index 0000000000..e772e17a82 --- /dev/null +++ b/internal/repository/target/eventstore.go @@ -0,0 +1,9 @@ +package target + +import "github.com/zitadel/zitadel/internal/eventstore" + +func init() { + eventstore.RegisterFilterEventMapper(AggregateType, AddedEventType, AddedEventMapper) + eventstore.RegisterFilterEventMapper(AggregateType, ChangedEventType, ChangedEventMapper) + eventstore.RegisterFilterEventMapper(AggregateType, RemovedEventType, RemovedEventMapper) +} diff --git a/internal/repository/target/target.go b/internal/repository/target/target.go new file mode 100644 index 0000000000..62b7f7826c --- /dev/null +++ b/internal/repository/target/target.go @@ -0,0 +1,199 @@ +package target + +import ( + "context" + "time" + + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/zerrors" +) + +const ( + eventTypePrefix eventstore.EventType = "target." + AddedEventType = eventTypePrefix + "added" + ChangedEventType = eventTypePrefix + "changed" + RemovedEventType = eventTypePrefix + "removed" +) + +type AddedEvent struct { + *eventstore.BaseEvent `json:"-"` + + Name string `json:"name"` + TargetType domain.TargetType `json:"targetType"` + URL string `json:"url"` + Timeout time.Duration `json:"timeout"` + Async bool `json:"async"` + InterruptOnError bool `json:"interruptOnError"` +} + +func (e *AddedEvent) SetBaseEvent(b *eventstore.BaseEvent) { + e.BaseEvent = b +} + +func (e *AddedEvent) Payload() any { + return e +} + +func (e *AddedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + return []*eventstore.UniqueConstraint{NewAddUniqueConstraint(e.Name)} +} + +func NewAddedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, + name string, + targetType domain.TargetType, + url string, + timeout time.Duration, + async bool, + interruptOnError bool, +) *AddedEvent { + return &AddedEvent{ + eventstore.NewBaseEventForPush( + ctx, aggregate, AddedEventType, + ), + name, targetType, url, timeout, async, interruptOnError} +} + +func AddedEventMapper(event eventstore.Event) (eventstore.Event, error) { + added := &AddedEvent{ + BaseEvent: eventstore.BaseEventFromRepo(event), + } + err := event.Unmarshal(added) + if err != nil { + return nil, zerrors.ThrowInternal(err, "TARGET-fx8f8yfbn1", "unable to unmarshal target added") + } + + return added, nil +} + +type ChangedEvent struct { + *eventstore.BaseEvent `json:"-"` + + Name *string `json:"name,omitempty"` + TargetType *domain.TargetType `json:"targetType,omitempty"` + URL *string `json:"url,omitempty"` + Timeout *time.Duration `json:"timeout,omitempty"` + Async *bool `json:"async,omitempty"` + InterruptOnError *bool `json:"interruptOnError,omitempty"` + + oldName string +} + +func (e *ChangedEvent) Payload() interface{} { + return e +} + +func (e *ChangedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + if e.oldName == "" { + return nil + } + return []*eventstore.UniqueConstraint{ + NewRemoveUniqueConstraint(e.oldName), + NewAddUniqueConstraint(*e.Name), + } +} + +func NewChangedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, + changes []Changes, +) *ChangedEvent { + changeEvent := &ChangedEvent{ + BaseEvent: eventstore.NewBaseEventForPush( + ctx, + aggregate, + ChangedEventType, + ), + } + for _, change := range changes { + change(changeEvent) + } + return changeEvent +} + +type Changes func(event *ChangedEvent) + +func ChangeName(oldName, name string) func(event *ChangedEvent) { + return func(e *ChangedEvent) { + e.Name = &name + e.oldName = oldName + } +} + +func ChangeTargetType(targetType domain.TargetType) func(event *ChangedEvent) { + return func(e *ChangedEvent) { + e.TargetType = &targetType + } +} + +func ChangeURL(url string) func(event *ChangedEvent) { + return func(e *ChangedEvent) { + e.URL = &url + } +} + +func ChangeTimeout(timeout time.Duration) func(event *ChangedEvent) { + return func(e *ChangedEvent) { + e.Timeout = &timeout + } +} + +func ChangeAsync(async bool) func(event *ChangedEvent) { + return func(e *ChangedEvent) { + e.Async = &async + } +} + +func ChangeInterruptOnError(interruptOnError bool) func(event *ChangedEvent) { + return func(e *ChangedEvent) { + e.InterruptOnError = &interruptOnError + } +} + +func ChangedEventMapper(event eventstore.Event) (eventstore.Event, error) { + changed := &ChangedEvent{ + BaseEvent: eventstore.BaseEventFromRepo(event), + } + err := event.Unmarshal(changed) + if err != nil { + return nil, zerrors.ThrowInternal(err, "TARGET-w6402p4ek7", "unable to unmarshal target changed") + } + + return changed, nil +} + +type RemovedEvent struct { + *eventstore.BaseEvent `json:"-"` + + name string +} + +func (e *RemovedEvent) SetBaseEvent(b *eventstore.BaseEvent) { + e.BaseEvent = b +} + +func (e *RemovedEvent) Payload() any { + return e +} + +func (e *RemovedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + return []*eventstore.UniqueConstraint{NewRemoveUniqueConstraint(e.name)} +} + +func NewRemovedEvent(ctx context.Context, aggregate *eventstore.Aggregate, name string) *RemovedEvent { + return &RemovedEvent{eventstore.NewBaseEventForPush(ctx, aggregate, RemovedEventType), name} +} + +func RemovedEventMapper(event eventstore.Event) (eventstore.Event, error) { + removed := &RemovedEvent{ + BaseEvent: eventstore.BaseEventFromRepo(event), + } + err := event.Unmarshal(removed) + if err != nil { + return nil, zerrors.ThrowInternal(err, "TARGET-0kuc12c7bc", "unable to unmarshal target removed") + } + + return removed, nil +} diff --git a/internal/static/i18n/bg.yaml b/internal/static/i18n/bg.yaml index 9715969807..db71abadfc 100644 --- a/internal/static/i18n/bg.yaml +++ b/internal/static/i18n/bg.yaml @@ -552,6 +552,11 @@ Errors: NotExisting: Функцията не съществува TypeNotSupported: Типът функция не се поддържа InvalidValue: Невалидна стойност за тази функция + Target: + Invalid: Целта е невалидна + NoTimeout: Целта няма време за изчакване + InvalidURL: Целта има невалиден URL адрес + NotFound: Целта не е намерена AggregateTypes: action: Действие @@ -563,8 +568,13 @@ AggregateTypes: usergrant: Предоставяне на потребител quota: Квота feature: Особеност + target: Целта EventTypes: + target: + added: Целта е създадена + changed: Целта е променена + removed: Целта е изтрита user: added: Добавен потребител selfregistered: Потребителят се регистрира сам diff --git a/internal/static/i18n/cs.yaml b/internal/static/i18n/cs.yaml index cf23b93d23..dcd0d0fcc2 100644 --- a/internal/static/i18n/cs.yaml +++ b/internal/static/i18n/cs.yaml @@ -532,6 +532,11 @@ Errors: NotExisting: Funkce neexistuje TypeNotSupported: Typ funkce není podporován InvalidValue: Neplatná hodnota pro tuto funkci + Target: + Invalid: Cíl je neplatný + NoTimeout: Cíl nemá časový limit + InvalidURL: Cíl má neplatnou adresu URL + NotFound: Cíl nenalezen AggregateTypes: action: Akce @@ -543,8 +548,13 @@ AggregateTypes: usergrant: Uživatelský grant quota: Kvóta feature: Funkce + target: Cíl EventTypes: + target: + added: Cíl vytvořen + changed: Cíl změněn + removed: Cíl smazán user: added: Uživatel přidán selfregistered: Uživatel se zaregistroval sám diff --git a/internal/static/i18n/de.yaml b/internal/static/i18n/de.yaml index 80ba38029e..0862f710be 100644 --- a/internal/static/i18n/de.yaml +++ b/internal/static/i18n/de.yaml @@ -535,6 +535,11 @@ Errors: NotExisting: Feature existiert nicht TypeNotSupported: Feature Typ wird nicht unterstützt InvalidValue: Ungültiger Wert für dieses Feature + Target: + Invalid: Ziel ist ungültig + NoTimeout: Ziel hat keinen Timeout + InvalidURL: Ziel hat eine ungültige URL + NotFound: Ziel nicht gefunden AggregateTypes: action: Action @@ -546,8 +551,13 @@ AggregateTypes: usergrant: Benutzerberechtigung quota: Kontingent feature: Feature + target: Ziel EventTypes: + target: + added: Ziel erstellt + changed: Ziel geändert + removed: Ziel gelöscht user: added: Benutzer hinzugefügt selfregistered: Benutzer hat sich selbst registriert diff --git a/internal/static/i18n/en.yaml b/internal/static/i18n/en.yaml index 88146f7260..c215e22938 100644 --- a/internal/static/i18n/en.yaml +++ b/internal/static/i18n/en.yaml @@ -535,6 +535,11 @@ Errors: NotExisting: Feature does not exist TypeNotSupported: Feature type is not supported InvalidValue: Invalid value for this feature + Target: + Invalid: Target is invalid + NoTimeout: Target has no timeout + InvalidURL: Target has an invalid URL + NotFound: Target not found AggregateTypes: action: Action @@ -546,8 +551,13 @@ AggregateTypes: usergrant: User grant quota: Quota feature: Feature + target: Target EventTypes: + target: + added: Target created + changed: Target changed + removed: Target deleted user: added: User added selfregistered: User registered himself diff --git a/internal/static/i18n/es.yaml b/internal/static/i18n/es.yaml index 3c8cec1727..e2039e19a4 100644 --- a/internal/static/i18n/es.yaml +++ b/internal/static/i18n/es.yaml @@ -535,6 +535,11 @@ Errors: NotExisting: La característica no existe TypeNotSupported: El tipo de característica no es compatible InvalidValue: Valor no válido para esta característica + Target: + Invalid: El objetivo no es válido + NoTimeout: El objetivo no tiene tiempo de espera + InvalidURL: El objetivo tiene una URL no válida + NotFound: El objetivo no encontrado AggregateTypes: action: Acción @@ -546,8 +551,13 @@ AggregateTypes: usergrant: Concesión de usuario quota: Cuota feature: Característica + target: Objectivo EventTypes: + target: + added: Objetivo creado + changed: Objetivo cambiado + removed: Objetivo eliminado user: added: Usuario añadido selfregistered: El usuario se registró por sí mismo diff --git a/internal/static/i18n/fr.yaml b/internal/static/i18n/fr.yaml index 934116ca58..2bd49cdc2f 100644 --- a/internal/static/i18n/fr.yaml +++ b/internal/static/i18n/fr.yaml @@ -535,6 +535,11 @@ Errors: NotExisting: La fonctionnalité n'existe pas TypeNotSupported: Le type de fonctionnalité n'est pas pris en charge InvalidValue: Valeur non valide pour cette fonctionnalité + Target: + Invalid: La cible n'est pas valide + NoTimeout: La cible n'a pas de délai d'attente + InvalidURL: La cible a une URL non valide + NotFound: La cible introuvable AggregateTypes: action: Action @@ -546,8 +551,13 @@ AggregateTypes: usergrant: Subvention de l'utilisateur quota: Contingent feature: Fonctionnalité + target: Cible EventTypes: + target: + added: Cible créée + changed: Cible modifiée + removed: Cible supprimée user: added: Utilisateur ajouté selfregistered: L'utilisateur s'est enregistré lui-même diff --git a/internal/static/i18n/it.yaml b/internal/static/i18n/it.yaml index c5447304e9..8c7ae6688c 100644 --- a/internal/static/i18n/it.yaml +++ b/internal/static/i18n/it.yaml @@ -536,6 +536,11 @@ Errors: NotExisting: La funzionalità non esiste TypeNotSupported: Il tipo di funzionalità non è supportato InvalidValue: Valore non valido per questa funzionalità + Target: + Invalid: Il target non è valido + NoTimeout: Il target non ha timeout + InvalidURL: La destinazione ha un URL non valido + NotFound: Obiettivo non trovato AggregateTypes: action: Azione @@ -547,8 +552,13 @@ AggregateTypes: usergrant: Sovvenzione utente quota: Quota feature: Funzionalità + target: Bersaglio EventTypes: + target: + added: Obiettivo creato + changed: Obiettivo cambiato + removed: Obiettivo eliminato user: added: Utente aggiunto selfregistered: L'utente si è registrato diff --git a/internal/static/i18n/ja.yaml b/internal/static/i18n/ja.yaml index 88d75e39f3..2ebc9711d0 100644 --- a/internal/static/i18n/ja.yaml +++ b/internal/static/i18n/ja.yaml @@ -524,6 +524,11 @@ Errors: NotExisting: 機能が存在しません TypeNotSupported: 機能タイプはサポートされていません InvalidValue: この機能には無効な値です + Target: + Invalid: ターゲットが無効です + NoTimeout: ターゲットにはタイムアウトがありません + InvalidURL: ターゲットに無効な URL があります + NotFound: ターゲットが見つかりません AggregateTypes: action: アクション @@ -535,8 +540,13 @@ AggregateTypes: usergrant: ユーザーグラント quota: クォータ feature: 特徴 + target: 目標 EventTypes: + target: + added: ターゲットが作成されました + changed: ターゲットが変更されました + removed: ターゲットが削除されました user: added: ユーザーの追加 selfregistered: ユーザー自身の登録 diff --git a/internal/static/i18n/mk.yaml b/internal/static/i18n/mk.yaml index 86ed884cc0..b55246875b 100644 --- a/internal/static/i18n/mk.yaml +++ b/internal/static/i18n/mk.yaml @@ -534,6 +534,11 @@ Errors: NotExisting: Функцијата не постои TypeNotSupported: Типот на функција не е поддржан InvalidValue: Неважечка вредност за оваа функција + Target: + Invalid: Целта е неважечка + NoTimeout: Целта нема тајмаут + InvalidURL: Целта има неважечка URL-адреса + NotFound: Целта не е пронајдена AggregateTypes: action: Акција @@ -545,8 +550,13 @@ AggregateTypes: usergrant: Овластување на корисник quota: Квота feature: Карактеристика + target: Цел EventTypes: + target: + added: Целта е избришана + changed: Целта е променета + removed: Целта е избришана user: added: Додаден корисник selfregistered: Корисникот се регистрираше сам diff --git a/internal/static/i18n/nl.yaml b/internal/static/i18n/nl.yaml index 52137d78d5..5a36ec1610 100644 --- a/internal/static/i18n/nl.yaml +++ b/internal/static/i18n/nl.yaml @@ -535,6 +535,11 @@ Errors: NotExisting: Functie bestaat niet TypeNotSupported: Functie type wordt niet ondersteund InvalidValue: Ongeldige waarde voor deze functie + Target: + Invalid: Doel is ongeldig + NoTimeout: Doel heeft geen time-out + InvalidURL: Doel heeft een ongeldige URL + NotFound: Doel niet gevonden AggregateTypes: action: Actie @@ -546,8 +551,13 @@ AggregateTypes: usergrant: Gebruikerstoekenning quota: Quota feature: Functie + target: Doel EventTypes: + target: + added: Doel gemaakt + changed: Doel gewijzigd + removed: Doel verwijderd user: added: Gebruiker toegevoegd selfregistered: Gebruiker heeft zichzelf geregistreerd diff --git a/internal/static/i18n/pl.yaml b/internal/static/i18n/pl.yaml index ec8a36c673..4cfc62f805 100644 --- a/internal/static/i18n/pl.yaml +++ b/internal/static/i18n/pl.yaml @@ -535,6 +535,11 @@ Errors: NotExisting: Funkcja nie istnieje TypeNotSupported: Typ funkcji nie jest obsługiwany InvalidValue: Nieprawidłowa wartość dla tej funkcji + Target: + Invalid: Cel jest nieprawidłowy + NoTimeout: Cel nie ma limitu czasu + InvalidURL: Cel ma nieprawidłowy adres URL + NotFound: Nie znaleziono celu AggregateTypes: action: Działanie @@ -546,8 +551,13 @@ AggregateTypes: usergrant: Uprawnienie użytkownika quota: Limit feature: Funkcja + target: Cel EventTypes: + target: + added: Cel został utworzony + changed: Cel zmieniony + removed: Cel usunięty user: added: Użytkownik dodany selfregistered: Użytkownik zarejestrował się diff --git a/internal/static/i18n/pt.yaml b/internal/static/i18n/pt.yaml index 990f939b25..b705f37867 100644 --- a/internal/static/i18n/pt.yaml +++ b/internal/static/i18n/pt.yaml @@ -529,6 +529,11 @@ Errors: NotExisting: O recurso não existe TypeNotSupported: O tipo de recurso não é compatível InvalidValue: Valor inválido para este recurso + Target: + Invalid: A meta é inválida + NoTimeout: O destino não tem tempo limite + InvalidURL: O destino tem um URL inválido + NotFound: Destino não encontrado AggregateTypes: action: Ação @@ -540,8 +545,13 @@ AggregateTypes: usergrant: Concessão de usuário quota: Cota feature: Recurso + target: objetivo EventTypes: + target: + added: Destino criado + changed: Destino alterada + removed: Destino excluído user: added: Usuário adicionado selfregistered: Usuário se registrou diff --git a/internal/static/i18n/ru.yaml b/internal/static/i18n/ru.yaml index c609cdef01..e8ba05475f 100644 --- a/internal/static/i18n/ru.yaml +++ b/internal/static/i18n/ru.yaml @@ -519,6 +519,16 @@ Errors: Invalid: Токен недействителен Expired: Срок действия токена истек InvalidClient: Токен не был выпущен для этого клиента + Feature: + NotExisting: ункция не существует + TypeNotSupported: Тип объекта не поддерживается + InvalidValue: Недопустимое значение для этой функции. + Target: + Invalid: Цель недействительна. + NoTimeout: У цели нет тайм-аута + InvalidURL: Цель имеет неверный URL-адрес + NotFound: Цель не найдена + AggregateTypes: action: Действие instance: Пример @@ -528,7 +538,14 @@ AggregateTypes: user: Пользователь usergrant: Разрешение пользователя quota: Квота + feature: Особенность + target: мишень + EventTypes: + target: + added: Цель создана + changed: Цель изменена + removed: Цель удалена. user: added: Добавлено пользователем selfregistered: Пользователь зарегистрировался сам diff --git a/internal/static/i18n/zh.yaml b/internal/static/i18n/zh.yaml index 4d21f82f12..482dc903e4 100644 --- a/internal/static/i18n/zh.yaml +++ b/internal/static/i18n/zh.yaml @@ -535,6 +535,11 @@ Errors: NotExisting: 功能不存在 TypeNotSupported: 不支持功能类型 InvalidValue: 此功能的值无效 + Target: + Invalid: 目标无效 + NoTimeout: 目标没有超时 + InvalidURL: 目标的 URL 无效 + NotFound: 未找到目标 AggregateTypes: action: 动作 @@ -546,8 +551,13 @@ AggregateTypes: usergrant: 用户授权 quota: 配额 feature: 特征 + target: 靶 EventTypes: + target: + added: 目标已创建 + changed: 目标改变 + removed: 目标已删除 user: added: 已添加用户 selfregistered: 自注册用户 diff --git a/proto/zitadel/execution/v3alpha/execution_service.proto b/proto/zitadel/execution/v3alpha/execution_service.proto new file mode 100644 index 0000000000..3384567e55 --- /dev/null +++ b/proto/zitadel/execution/v3alpha/execution_service.proto @@ -0,0 +1,293 @@ +syntax = "proto3"; + +package zitadel.execution.v3alpha; + +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"; +import "zitadel/execution/v3alpha/target.proto"; +import "zitadel/object/v2beta/object.proto"; +import "zitadel/protoc_gen_zitadel/v2/options.proto"; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/execution/v3alpha;execution"; + +option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = { + info: { + title: "Execution Service"; + version: "3.0-alpha"; + description: "This API is intended to manage custom executions (previously known as actions) in a ZITADEL instance. This project is in alpha state. It can AND will continue breaking until the services provide the same functionality as the current actions."; + 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: "$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"; + } + } + } + } +}; + +service ExecutionService { + + // Create a target + // + // Create a new target, which can be used in executions. + rpc CreateTarget (CreateTargetRequest) returns (CreateTargetResponse) { + option (google.api.http) = { + post: "/v3alpha/targets" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "execution.target.write" + } + http_response: { + success_code: 201 + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "201"; + value: { + description: "Target successfully created"; + schema: { + json_schema: { + ref: "#/definitions/v3alphaCreateTargetResponse"; + } + } + }; + }; + }; + } + + // Update a target + // + // Update an existing target. + rpc UpdateTarget (UpdateTargetRequest) returns (UpdateTargetResponse) { + option (google.api.http) = { + put: "/v3alpha/targets/{target_id}" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "execution.target.write" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "Target successfully updated"; + }; + }; + }; + } + + // Delete a target + // + // Delete an existing target. This will remove it from any configured execution as well. + rpc DeleteTarget (DeleteTargetRequest) returns (DeleteTargetResponse) { + option (google.api.http) = { + delete: "/v3alpha/targets/{target_id}" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "execution.target.delete" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "Target successfully deleted"; + }; + }; + }; + } +} + +message CreateTargetRequest { + // Unique name of the target. + 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: "\"ip_allow_list\""; + } + ]; + // Defines the target type and how the response of the target is treated. + oneof target_type { + option (validate.required) = true; + + SetRESTWebhook rest_webhook = 2; + SetRESTRequestResponse rest_request_response = 3; + } + // Timeout defines the duration until ZITADEL cancels the execution. + google.protobuf.Duration timeout = 4 [ + (validate.rules).duration = {gt: {seconds: 0}, required: true}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"10s\""; + } + ]; + oneof execution_type { + // Set the execution to run asynchronously. + bool is_async = 5; + // Define if any error stops the whole execution. By default the process continues as normal. + bool interrupt_on_error = 6; + } +} + +message CreateTargetResponse { + // ID is the read-only unique identifier of the target. + string id = 1; + // Details provide some base information (such as the last change date) of the target. + zitadel.object.v2beta.Details details = 2; +} + +message UpdateTargetRequest { + // unique identifier of the target. + string target_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: "\"69629026806489455\""; + } + ]; + // Optionally change the unique name of the target. + 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: "\"ip_allow_list\""; + } + ]; + // Optionally change the target type and how the response of the target is treated, + // or its target URL. + oneof target_type { + SetRESTWebhook rest_webhook = 3; + SetRESTRequestResponse rest_request_response = 4; + } + // Optionally change the timeout, which defines the duration until ZITADEL cancels the execution. + optional google.protobuf.Duration timeout = 5 [ + (validate.rules).duration = {gt: {seconds: 0}}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"10s\""; + } + ]; + oneof execution_type { + // Set the execution to run asynchronously. + bool is_async = 6; + // Define if any error stops the whole execution. By default the process continues as normal. + bool interrupt_on_error = 7; + } +} + +message UpdateTargetResponse { + // Details provide some base information (such as the last change date) of the target. + zitadel.object.v2beta.Details details = 1; +} + +message DeleteTargetRequest { + // unique identifier of the target. + string target_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: "\"69629026806489455\""; + } + ]; +} + +message DeleteTargetResponse { + // Details provide some base information (such as the last change date) of the target. + zitadel.object.v2beta.Details details = 1; +} diff --git a/proto/zitadel/execution/v3alpha/target.proto b/proto/zitadel/execution/v3alpha/target.proto new file mode 100644 index 0000000000..c6991babeb --- /dev/null +++ b/proto/zitadel/execution/v3alpha/target.proto @@ -0,0 +1,38 @@ +syntax = "proto3"; + +package zitadel.execution.v3alpha; + +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"; +import "zitadel/object/v2beta/object.proto"; +import "zitadel/protoc_gen_zitadel/v2/options.proto"; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/execution/v3alpha;execution"; + +message SetRESTWebhook { + string url = 1 [ + (validate.rules).string = {min_len: 1, max_len: 1000, uri: true}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1, + max_length: 1000, + example: "\"https://example.com/hooks/ip_check\""; + } + ]; +} + +message SetRESTRequestResponse { + string url = 1 [ + (validate.rules).string = {min_len: 1, max_len: 1000, uri: true}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1, + max_length: 1000, + example: "\"https://example.com/hooks/ip_check\""; + } + ]; +} \ No newline at end of file