From c2a0b9d18754530b27fc9698b03254a822872f78 Mon Sep 17 00:00:00 2001 From: Livio Spring Date: Tue, 28 Oct 2025 14:01:14 +0100 Subject: [PATCH] feat(api): move instance service to v2 (#10919) # Which Problems Are Solved As part of our efforts to simplify the structure and versions of our APIs, were moving all existing v2beta endpoints to v2 and deprecate them. They will be removed in Zitadel V5. # How the Problems Are Solved - This PR moves instance v2beta service and its endpoints to a corresponding v2 version. The v2beta service and endpoints are deprecated. - The docs are moved to the new GA service and its endpoints. The v2beta is not displayed anymore. - The comments and have been improved and, where not already done, moved from swagger annotations to proto. - All required fields have been marked with (google.api.field_behavior) = REQUIRED and validation rules have been added where missing - `Domain` has been renamed to `CustomDomain` to align with naming conventions - `..Query` has been renamed to `..Filter` to align with other services - The `instance_id` parameter can now passed on all endpoints and is properly used, but requires `system` permissions. It can be omitted to use the own instance (identified by context as any other service). - The following endpoints are affected: - GetInstance - UpdateInstance - ListCustomDomains - AddTrustedDomain - RemoveTrustedDomain - ListTrustedDomains - InstanceService has been added the InstanceInterceptor's `explicitInstanceIdServices` to allow passing the id - If the instance is not found by id, the error is not directly returned to prevent enumeration. - Permissions are checked in the API instead of the interceptor for these calls. - Setting the same instance name in the update no longer returns an error, but the previous change date. # Additional Changes none # Additional Context - part of https://github.com/zitadel/zitadel/issues/10772 - requires backport to v4.x --- cmd/start/start.go | 8 +- docs/docusaurus.config.js | 2 +- docs/sidebars.js | 6 +- internal/api/api.go | 3 +- internal/api/grpc/instance/v2/converter.go | 246 +++++++++ .../api/grpc/instance/v2/converter_test.go | 391 ++++++++++++++ internal/api/grpc/instance/v2/domain.go | 62 +++ internal/api/grpc/instance/v2/instance.go | 39 ++ .../v2/integration_test/domain_test.go | 349 ++++++++++++ .../v2/integration_test/instance_test.go | 252 +++++++++ .../v2/integration_test/query_test.go | 427 +++++++++++++++ internal/api/grpc/instance/v2/query.go | 84 +++ internal/api/grpc/instance/v2/server.go | 74 +++ .../instance_interceptor.go | 16 +- internal/command/instance.go | 39 +- internal/command/instance_test.go | 24 +- internal/domain/permission.go | 4 + internal/integration/client.go | 9 +- proto/zitadel/admin.proto | 10 +- proto/zitadel/instance/v2/instance.proto | 205 +++++++ .../instance/v2/instance_service.proto | 508 ++++++++++++++++++ .../instance/v2beta/instance_service.proto | 32 ++ proto/zitadel/system.proto | 16 +- 23 files changed, 2736 insertions(+), 70 deletions(-) create mode 100644 internal/api/grpc/instance/v2/converter.go create mode 100644 internal/api/grpc/instance/v2/converter_test.go create mode 100644 internal/api/grpc/instance/v2/domain.go create mode 100644 internal/api/grpc/instance/v2/instance.go create mode 100644 internal/api/grpc/instance/v2/integration_test/domain_test.go create mode 100644 internal/api/grpc/instance/v2/integration_test/instance_test.go create mode 100644 internal/api/grpc/instance/v2/integration_test/query_test.go create mode 100644 internal/api/grpc/instance/v2/query.go create mode 100644 internal/api/grpc/instance/v2/server.go create mode 100644 proto/zitadel/instance/v2/instance.proto create mode 100644 proto/zitadel/instance/v2/instance_service.proto diff --git a/cmd/start/start.go b/cmd/start/start.go index 84b38fe83..235f99ec6 100644 --- a/cmd/start/start.go +++ b/cmd/start/start.go @@ -49,7 +49,8 @@ import ( feature_v2beta "github.com/zitadel/zitadel/internal/api/grpc/feature/v2beta" group_v2 "github.com/zitadel/zitadel/internal/api/grpc/group/v2" idp_v2 "github.com/zitadel/zitadel/internal/api/grpc/idp/v2" - instance "github.com/zitadel/zitadel/internal/api/grpc/instance/v2beta" + instance_v2 "github.com/zitadel/zitadel/internal/api/grpc/instance/v2" + instance_v2beta "github.com/zitadel/zitadel/internal/api/grpc/instance/v2beta" internal_permission_v2 "github.com/zitadel/zitadel/internal/api/grpc/internal_permission/v2" internal_permission_v2beta "github.com/zitadel/zitadel/internal/api/grpc/internal_permission/v2beta" "github.com/zitadel/zitadel/internal/api/grpc/management" @@ -488,7 +489,10 @@ func startAPIs( if err := apis.RegisterServer(ctx, system.CreateServer(commands, queries, config.Database.DatabaseName(), config.DefaultInstance, config.ExternalDomain), tlsConfig); err != nil { return nil, err } - if err := apis.RegisterService(ctx, instance.CreateServer(commands, queries, config.Database.DatabaseName(), config.DefaultInstance, config.ExternalDomain)); err != nil { + if err := apis.RegisterService(ctx, instance_v2beta.CreateServer(commands, queries, config.Database.DatabaseName(), config.DefaultInstance, config.ExternalDomain)); err != nil { + return nil, err + } + if err := apis.RegisterService(ctx, instance_v2.CreateServer(commands, queries, config.DefaultInstance, config.ExternalDomain, permissionCheck)); err != nil { return nil, err } if err := apis.RegisterServer(ctx, admin.CreateServer(config.Database.DatabaseName(), commands, queries, keys.User, config.AuditLogRetention), tlsConfig); err != nil { diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js index a5280334e..e8786d161 100644 --- a/docs/docusaurus.config.js +++ b/docs/docusaurus.config.js @@ -410,7 +410,7 @@ module.exports = { }, instance_v2: { specPath: - ".artifacts/openapi3/zitadel/instance/v2beta/instance_service.openapi.yaml", + ".artifacts/openapi3/zitadel/instance/v2/instance_service.openapi.yaml", outputDir: "docs/apis/resources/instance_service_v2", sidebarOptions: { groupPathsBy: "tag", diff --git a/docs/sidebars.js b/docs/sidebars.js index 78604dfb0..452fb6730 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -842,16 +842,14 @@ module.exports = { }, { type: "category", - label: "Instance (Beta)", + label: "Instance", link: { type: "generated-index", - title: "Instance Service API (Beta)", + title: "Instance Service API", slug: "/apis/resources/instance_service_v2", description: "This API is intended to manage instances, custom domains and trusted domains in ZITADEL.\n" + "\n" + - "This service is in beta state. It can AND will continue breaking until a stable version is released.\n" + - "\n" + "This v2 of the API provides the same functionalities as the v1, but organised on a per resource basis.\n" + "The whole functionality related to domains (custom and trusted) has been moved under this instance API." , diff --git a/internal/api/api.go b/internal/api/api.go index d92532a30..7641c56c6 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -28,6 +28,7 @@ import ( "github.com/zitadel/zitadel/internal/telemetry/metrics" "github.com/zitadel/zitadel/internal/telemetry/tracing" "github.com/zitadel/zitadel/internal/zerrors" + instance_pb "github.com/zitadel/zitadel/pkg/grpc/instance/v2" system_pb "github.com/zitadel/zitadel/pkg/grpc/system" ) @@ -192,7 +193,7 @@ func (a *API) registerConnectServer(service server.ConnectServer) { connect_middleware.CallDurationHandler(), connect_middleware.MetricsHandler(metricTypes, grpc_api.Probes...), connect_middleware.NoCacheInterceptor(), - connect_middleware.InstanceInterceptor(a.queries, a.externalDomain, a.translator, system_pb.SystemService_ServiceDesc.ServiceName, healthpb.Health_ServiceDesc.ServiceName), + connect_middleware.InstanceInterceptor(a.queries, a.externalDomain, a.translator, system_pb.SystemService_ServiceDesc.ServiceName, healthpb.Health_ServiceDesc.ServiceName, instance_pb.InstanceService_ServiceDesc.ServiceName), connect_middleware.AccessStorageInterceptor(a.accessInterceptor.AccessService()), connect_middleware.ErrorHandler(), connect_middleware.LimitsInterceptor(system_pb.SystemService_ServiceDesc.ServiceName), diff --git a/internal/api/grpc/instance/v2/converter.go b/internal/api/grpc/instance/v2/converter.go new file mode 100644 index 000000000..d60bfbe64 --- /dev/null +++ b/internal/api/grpc/instance/v2/converter.go @@ -0,0 +1,246 @@ +package instance + +import ( + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/cmd/build" + "github.com/zitadel/zitadel/internal/api/grpc/filter/v2" + "github.com/zitadel/zitadel/internal/api/grpc/object/v2" + "github.com/zitadel/zitadel/internal/config/systemdefaults" + "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/internal/zerrors" + "github.com/zitadel/zitadel/pkg/grpc/instance/v2" +) + +func InstancesToPb(instances []*query.Instance) []*instance.Instance { + list := []*instance.Instance{} + for _, instance := range instances { + list = append(list, ToProtoObject(instance)) + } + return list +} + +func ToProtoObject(inst *query.Instance) *instance.Instance { + return &instance.Instance{ + Id: inst.ID, + Name: inst.Name, + CustomDomains: DomainsToPb(inst.Domains), + Version: build.Version(), + ChangeDate: timestamppb.New(inst.ChangeDate), + CreationDate: timestamppb.New(inst.CreationDate), + } +} + +func DomainsToPb(domains []*query.InstanceDomain) []*instance.CustomDomain { + d := []*instance.CustomDomain{} + for _, dm := range domains { + pbDomain := DomainToPb(dm) + d = append(d, pbDomain) + } + return d +} + +func DomainToPb(d *query.InstanceDomain) *instance.CustomDomain { + return &instance.CustomDomain{ + Domain: d.Domain, + Primary: d.IsPrimary, + Generated: d.IsGenerated, + InstanceId: d.InstanceID, + CreationDate: timestamppb.New(d.CreationDate), + } +} + +func ListInstancesRequestToModel(req *instance.ListInstancesRequest, sysDefaults systemdefaults.SystemDefaults) (*query.InstanceSearchQueries, error) { + offset, limit, asc, err := filter.PaginationPbToQuery(sysDefaults, req.GetPagination()) + if err != nil { + return nil, err + } + + queries, err := filtersToQueries(req.GetFilters()) + if err != nil { + return nil, err + } + + return &query.InstanceSearchQueries{ + SearchRequest: query.SearchRequest{ + Offset: offset, + Limit: limit, + Asc: asc, + SortingColumn: fieldNameToInstanceColumn(req.GetSortingColumn()), + }, + Queries: queries, + }, nil + +} + +func fieldNameToInstanceColumn(fieldName instance.FieldName) query.Column { + switch fieldName { + case instance.FieldName_FIELD_NAME_ID: + return query.InstanceColumnID + case instance.FieldName_FIELD_NAME_NAME: + return query.InstanceColumnName + case instance.FieldName_FIELD_NAME_CREATION_DATE: + return query.InstanceColumnCreationDate + case instance.FieldName_FIELD_NAME_UNSPECIFIED: + fallthrough + default: + return query.Column{} + } +} + +func filtersToQueries(filters []*instance.Filter) (_ []query.SearchQuery, err error) { + q := []query.SearchQuery{} + for _, filter := range filters { + model, err := instanceFilterToQuery(filter) + if err != nil { + return nil, err + } + q = append(q, model) + } + return q, nil +} + +func instanceFilterToQuery(filter *instance.Filter) (query.SearchQuery, error) { + switch q := filter.GetFilter().(type) { + case *instance.Filter_InIdsFilter: + return query.NewInstanceIDsListSearchQuery(q.InIdsFilter.GetIds()...) + case *instance.Filter_CustomDomainsFilter: + return query.NewInstanceDomainsListSearchQuery(q.CustomDomainsFilter.GetDomains()...) + default: + return nil, zerrors.ThrowInvalidArgument(nil, "INST-3m0se", "List.Query.Invalid") + } +} + +func ListCustomDomainsRequestToModel(req *instance.ListCustomDomainsRequest, defaults systemdefaults.SystemDefaults) (*query.InstanceDomainSearchQueries, error) { + offset, limit, asc, err := filter.PaginationPbToQuery(defaults, req.GetPagination()) + if err != nil { + return nil, err + } + + queries, err := customDomainFiltersToQueries(req.GetFilters()) + if err != nil { + return nil, err + } + + return &query.InstanceDomainSearchQueries{ + SearchRequest: query.SearchRequest{ + Offset: offset, + Limit: limit, + Asc: asc, + SortingColumn: fieldNameToInstanceDomainColumn(req.GetSortingColumn()), + }, + Queries: queries, + }, nil +} + +func fieldNameToInstanceDomainColumn(fieldName instance.DomainFieldName) query.Column { + switch fieldName { + case instance.DomainFieldName_DOMAIN_FIELD_NAME_DOMAIN: + return query.InstanceDomainDomainCol + case instance.DomainFieldName_DOMAIN_FIELD_NAME_GENERATED: + return query.InstanceDomainIsGeneratedCol + case instance.DomainFieldName_DOMAIN_FIELD_NAME_PRIMARY: + return query.InstanceDomainIsPrimaryCol + case instance.DomainFieldName_DOMAIN_FIELD_NAME_CREATION_DATE: + return query.InstanceDomainCreationDateCol + case instance.DomainFieldName_DOMAIN_FIELD_NAME_UNSPECIFIED: + fallthrough + default: + return query.Column{} + } +} + +func customDomainFiltersToQueries(filters []*instance.CustomDomainFilter) (_ []query.SearchQuery, err error) { + q := make([]query.SearchQuery, len(filters)) + for i, filter := range filters { + q[i], err = customDomainFilterToQuery(filter) + if err != nil { + return nil, err + } + } + return q, nil +} + +func customDomainFilterToQuery(filter *instance.CustomDomainFilter) (query.SearchQuery, error) { + switch q := filter.GetFilter().(type) { + case *instance.CustomDomainFilter_DomainFilter: + return query.NewInstanceDomainDomainSearchQuery(object.TextMethodToQuery(q.DomainFilter.GetMethod()), q.DomainFilter.GetDomain()) + case *instance.CustomDomainFilter_GeneratedFilter: + return query.NewInstanceDomainGeneratedSearchQuery(q.GeneratedFilter) + case *instance.CustomDomainFilter_PrimaryFilter: + return query.NewInstanceDomainPrimarySearchQuery(q.PrimaryFilter) + default: + return nil, zerrors.ThrowInvalidArgument(nil, "INST-Ags42", "List.Query.Invalid") + } +} + +func ListTrustedDomainsRequestToModel(req *instance.ListTrustedDomainsRequest, defaults systemdefaults.SystemDefaults) (*query.InstanceTrustedDomainSearchQueries, error) { + offset, limit, asc, err := filter.PaginationPbToQuery(defaults, req.GetPagination()) + if err != nil { + return nil, err + } + + queries, err := trustedDomainFiltersToQueries(req.GetFilters()) + if err != nil { + return nil, err + } + + return &query.InstanceTrustedDomainSearchQueries{ + SearchRequest: query.SearchRequest{ + Offset: offset, + Limit: limit, + Asc: asc, + SortingColumn: fieldNameToInstanceTrustedDomainColumn(req.GetSortingColumn()), + }, + Queries: queries, + }, nil +} + +func trustedDomainFiltersToQueries(filters []*instance.TrustedDomainFilter) (_ []query.SearchQuery, err error) { + q := make([]query.SearchQuery, len(filters)) + for i, filter := range filters { + q[i], err = trustedDomainQueryToModel(filter) + if err != nil { + return nil, err + } + } + return q, nil +} + +func trustedDomainQueryToModel(filter *instance.TrustedDomainFilter) (query.SearchQuery, error) { + switch q := filter.GetFilter().(type) { + case *instance.TrustedDomainFilter_DomainFilter: + return query.NewInstanceTrustedDomainDomainSearchQuery(object.TextMethodToQuery(q.DomainFilter.GetMethod()), q.DomainFilter.GetDomain()) + default: + return nil, zerrors.ThrowInvalidArgument(nil, "INST-Ags42", "List.Query.Invalid") + } +} + +func trustedDomainsToPb(domains []*query.InstanceTrustedDomain) []*instance.TrustedDomain { + d := make([]*instance.TrustedDomain, len(domains)) + for i, domain := range domains { + d[i] = trustedDomainToPb(domain) + } + return d +} + +func trustedDomainToPb(d *query.InstanceTrustedDomain) *instance.TrustedDomain { + return &instance.TrustedDomain{ + Domain: d.Domain, + InstanceId: d.InstanceID, + CreationDate: timestamppb.New(d.CreationDate), + } +} + +func fieldNameToInstanceTrustedDomainColumn(fieldName instance.TrustedDomainFieldName) query.Column { + switch fieldName { + case instance.TrustedDomainFieldName_TRUSTED_DOMAIN_FIELD_NAME_DOMAIN: + return query.InstanceTrustedDomainDomainCol + case instance.TrustedDomainFieldName_TRUSTED_DOMAIN_FIELD_NAME_CREATION_DATE: + return query.InstanceTrustedDomainCreationDateCol + case instance.TrustedDomainFieldName_TRUSTED_DOMAIN_FIELD_NAME_UNSPECIFIED: + fallthrough + default: + return query.Column{} + } +} diff --git a/internal/api/grpc/instance/v2/converter_test.go b/internal/api/grpc/instance/v2/converter_test.go new file mode 100644 index 000000000..c8086b3b8 --- /dev/null +++ b/internal/api/grpc/instance/v2/converter_test.go @@ -0,0 +1,391 @@ +package instance + +import ( + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/cmd/build" + "github.com/zitadel/zitadel/internal/config/systemdefaults" + "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/internal/zerrors" + "github.com/zitadel/zitadel/pkg/grpc/filter/v2" + "github.com/zitadel/zitadel/pkg/grpc/instance/v2" + "github.com/zitadel/zitadel/pkg/grpc/object/v2" +) + +func Test_InstancesToPb(t *testing.T) { + instances := []*query.Instance{ + { + ID: "instance1", + Name: "Instance One", + Domains: []*query.InstanceDomain{ + { + Domain: "example.com", + IsPrimary: true, + IsGenerated: false, + Sequence: 1, + CreationDate: time.Unix(123, 0), + ChangeDate: time.Unix(124, 0), + InstanceID: "instance1", + }, + }, + Sequence: 1, + CreationDate: time.Unix(123, 0), + ChangeDate: time.Unix(124, 0), + }, + } + + want := []*instance.Instance{ + { + Id: "instance1", + Name: "Instance One", + CustomDomains: []*instance.CustomDomain{ + { + Domain: "example.com", + Primary: true, + Generated: false, + InstanceId: "instance1", + CreationDate: ×tamppb.Timestamp{Seconds: 123}, + }, + }, + Version: build.Version(), + ChangeDate: ×tamppb.Timestamp{Seconds: 124}, + CreationDate: ×tamppb.Timestamp{Seconds: 123}, + }, + } + + got := InstancesToPb(instances) + assert.Equal(t, want, got) +} + +func Test_ListInstancesRequestToModel(t *testing.T) { + t.Parallel() + + searchInstanceByID, err := query.NewInstanceIDsListSearchQuery("instance1", "instance2") + require.Nil(t, err) + + tt := []struct { + testName string + inputRequest *instance.ListInstancesRequest + maxQueryLimit uint64 + expectedResult *query.InstanceSearchQueries + expectedError error + }{ + { + testName: "when query limit exceeds max query limit should return invalid argument error", + maxQueryLimit: 1, + inputRequest: &instance.ListInstancesRequest{ + Pagination: &filter.PaginationRequest{Limit: 10, Offset: 0, Asc: true}, + SortingColumn: instance.FieldName_FIELD_NAME_ID, + Filters: []*instance.Filter{{Filter: &instance.Filter_InIdsFilter{InIdsFilter: &filter.InIDsFilter{Ids: []string{"instance1", "instance2"}}}}}, + }, + expectedError: zerrors.ThrowInvalidArgumentf(errors.New("given: 10, allowed: 1"), "QUERY-4M0fs", "Errors.Query.LimitExceeded"), + }, + { + testName: "when valid request should return instance search query model", + inputRequest: &instance.ListInstancesRequest{ + Pagination: &filter.PaginationRequest{Limit: 10, Offset: 0, Asc: true}, + SortingColumn: instance.FieldName_FIELD_NAME_ID, + Filters: []*instance.Filter{{Filter: &instance.Filter_InIdsFilter{InIdsFilter: &filter.InIDsFilter{Ids: []string{"instance1", "instance2"}}}}}, + }, + expectedResult: &query.InstanceSearchQueries{ + SearchRequest: query.SearchRequest{ + Offset: 0, + Limit: 10, + Asc: true, + SortingColumn: query.InstanceColumnID, + }, + Queries: []query.SearchQuery{searchInstanceByID}, + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + sysDefaults := systemdefaults.SystemDefaults{MaxQueryLimit: tc.maxQueryLimit} + + got, err := ListInstancesRequestToModel(tc.inputRequest, sysDefaults) + assert.Equal(t, tc.expectedError, err) + assert.Equal(t, tc.expectedResult, got) + + }) + } +} + +func Test_fieldNameToInstanceColumn(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + fieldName instance.FieldName + want query.Column + }{ + { + name: "ID field", + fieldName: instance.FieldName_FIELD_NAME_ID, + want: query.InstanceColumnID, + }, + { + name: "Name field", + fieldName: instance.FieldName_FIELD_NAME_NAME, + want: query.InstanceColumnName, + }, + { + name: "Creation Date field", + fieldName: instance.FieldName_FIELD_NAME_CREATION_DATE, + want: query.InstanceColumnCreationDate, + }, + { + name: "Unknown field", + fieldName: instance.FieldName(99), + want: query.Column{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := fieldNameToInstanceColumn(tt.fieldName) + assert.Equal(t, tt.want, got) + }) + } +} + +func Test_instanceQueryToModel(t *testing.T) { + t.Parallel() + + searchInstanceByID, err := query.NewInstanceIDsListSearchQuery("instance1") + require.Nil(t, err) + + searchInstanceByDomain, err := query.NewInstanceDomainsListSearchQuery("example.com") + require.Nil(t, err) + + tests := []struct { + name string + searchQuery *instance.Filter + want query.SearchQuery + wantErr bool + }{ + { + name: "ID Query", + searchQuery: &instance.Filter{ + Filter: &instance.Filter_InIdsFilter{ + InIdsFilter: &filter.InIDsFilter{ + Ids: []string{"instance1"}, + }, + }, + }, + want: searchInstanceByID, + wantErr: false, + }, + { + name: "Domain Query", + searchQuery: &instance.Filter{ + Filter: &instance.Filter_CustomDomainsFilter{ + CustomDomainsFilter: &instance.CustomDomainsFilter{ + Domains: []string{"example.com"}, + }, + }, + }, + want: searchInstanceByDomain, + wantErr: false, + }, + { + name: "Invalid Query", + searchQuery: &instance.Filter{ + Filter: nil, + }, + want: nil, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, err := instanceFilterToQuery(tt.searchQuery) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.want, got) + } + }) + } +} + +func Test_ListCustomDomainsRequestToModel(t *testing.T) { + t.Parallel() + + querySearchRes, err := query.NewInstanceDomainDomainSearchQuery(query.TextEquals, "example.com") + require.Nil(t, err) + + queryGeneratedRes, err := query.NewInstanceDomainGeneratedSearchQuery(false) + require.Nil(t, err) + + tests := []struct { + name string + inputRequest *instance.ListCustomDomainsRequest + maxQueryLimit uint64 + expectedResult *query.InstanceDomainSearchQueries + expectedError error + }{ + { + name: "when query limit exceeds max query limit should return invalid argument error", + inputRequest: &instance.ListCustomDomainsRequest{ + Pagination: &filter.PaginationRequest{Limit: 10, Offset: 0, Asc: true}, + SortingColumn: instance.DomainFieldName_DOMAIN_FIELD_NAME_DOMAIN, + Filters: []*instance.CustomDomainFilter{ + { + Filter: &instance.CustomDomainFilter_DomainFilter{ + DomainFilter: &instance.DomainFilter{ + Method: object.TextQueryMethod_TEXT_QUERY_METHOD_EQUALS, + Domain: "example.com", + }, + }, + }, + }, + }, + maxQueryLimit: 1, + expectedError: zerrors.ThrowInvalidArgumentf(errors.New("given: 10, allowed: 1"), "QUERY-4M0fs", "Errors.Query.LimitExceeded"), + }, + { + name: "when valid request should return domain search query model", + inputRequest: &instance.ListCustomDomainsRequest{ + Pagination: &filter.PaginationRequest{Limit: 10, Offset: 0, Asc: true}, + SortingColumn: instance.DomainFieldName_DOMAIN_FIELD_NAME_PRIMARY, + Filters: []*instance.CustomDomainFilter{ + { + Filter: &instance.CustomDomainFilter_DomainFilter{ + DomainFilter: &instance.DomainFilter{Method: object.TextQueryMethod_TEXT_QUERY_METHOD_EQUALS, Domain: "example.com"}}, + }, + { + Filter: &instance.CustomDomainFilter_GeneratedFilter{ + GeneratedFilter: false, + }, + }, + }, + }, + maxQueryLimit: 100, + expectedResult: &query.InstanceDomainSearchQueries{ + SearchRequest: query.SearchRequest{Offset: 0, Limit: 10, Asc: true, SortingColumn: query.InstanceDomainIsPrimaryCol}, + Queries: []query.SearchQuery{ + querySearchRes, + queryGeneratedRes, + }, + }, + expectedError: nil, + }, + { + name: "when invalid query should return error", + inputRequest: &instance.ListCustomDomainsRequest{ + Pagination: &filter.PaginationRequest{Limit: 10, Offset: 0, Asc: true}, + SortingColumn: instance.DomainFieldName_DOMAIN_FIELD_NAME_GENERATED, + Filters: []*instance.CustomDomainFilter{ + { + Filter: nil, + }, + }, + }, + maxQueryLimit: 100, + expectedResult: nil, + expectedError: zerrors.ThrowInvalidArgument(nil, "INST-Ags42", "List.Query.Invalid"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + sysDefaults := systemdefaults.SystemDefaults{MaxQueryLimit: tt.maxQueryLimit} + + got, err := ListCustomDomainsRequestToModel(tt.inputRequest, sysDefaults) + assert.Equal(t, tt.expectedError, err) + assert.Equal(t, tt.expectedResult, got) + }) + } +} + +func Test_ListTrustedDomainsRequestToModel(t *testing.T) { + t.Parallel() + + querySearchRes, err := query.NewInstanceTrustedDomainDomainSearchQuery(query.TextEquals, "example.com") + require.Nil(t, err) + + tests := []struct { + name string + inputRequest *instance.ListTrustedDomainsRequest + maxQueryLimit uint64 + expectedResult *query.InstanceTrustedDomainSearchQueries + expectedError error + }{ + { + name: "when query limit exceeds max query limit should return invalid argument error", + inputRequest: &instance.ListTrustedDomainsRequest{ + Pagination: &filter.PaginationRequest{Limit: 10, Offset: 0, Asc: true}, + SortingColumn: instance.TrustedDomainFieldName_TRUSTED_DOMAIN_FIELD_NAME_DOMAIN, + Filters: []*instance.TrustedDomainFilter{ + { + Filter: &instance.TrustedDomainFilter_DomainFilter{ + DomainFilter: &instance.DomainFilter{ + Method: object.TextQueryMethod_TEXT_QUERY_METHOD_EQUALS, + Domain: "example.com", + }, + }, + }, + }, + }, + maxQueryLimit: 1, + expectedError: zerrors.ThrowInvalidArgumentf(errors.New("given: 10, allowed: 1"), "QUERY-4M0fs", "Errors.Query.LimitExceeded"), + }, + { + name: "when valid request should return domain search query model", + inputRequest: &instance.ListTrustedDomainsRequest{ + Pagination: &filter.PaginationRequest{Limit: 10, Offset: 0, Asc: true}, + SortingColumn: instance.TrustedDomainFieldName_TRUSTED_DOMAIN_FIELD_NAME_CREATION_DATE, + Filters: []*instance.TrustedDomainFilter{ + { + Filter: &instance.TrustedDomainFilter_DomainFilter{ + DomainFilter: &instance.DomainFilter{Method: object.TextQueryMethod_TEXT_QUERY_METHOD_EQUALS, Domain: "example.com"}}, + }, + }, + }, + maxQueryLimit: 100, + expectedResult: &query.InstanceTrustedDomainSearchQueries{ + SearchRequest: query.SearchRequest{Offset: 0, Limit: 10, Asc: true, SortingColumn: query.InstanceTrustedDomainCreationDateCol}, + Queries: []query.SearchQuery{querySearchRes}, + }, + expectedError: nil, + }, + { + name: "when invalid query should return error", + inputRequest: &instance.ListTrustedDomainsRequest{ + Pagination: &filter.PaginationRequest{Limit: 10, Offset: 0, Asc: true}, + Filters: []*instance.TrustedDomainFilter{ + { + Filter: nil, + }, + }, + }, + maxQueryLimit: 100, + expectedResult: nil, + expectedError: zerrors.ThrowInvalidArgument(nil, "INST-Ags42", "List.Query.Invalid"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + sysDefaults := systemdefaults.SystemDefaults{MaxQueryLimit: tt.maxQueryLimit} + + got, err := ListTrustedDomainsRequestToModel(tt.inputRequest, sysDefaults) + assert.Equal(t, tt.expectedError, err) + assert.Equal(t, tt.expectedResult, got) + }) + } +} diff --git a/internal/api/grpc/instance/v2/domain.go b/internal/api/grpc/instance/v2/domain.go new file mode 100644 index 000000000..994491053 --- /dev/null +++ b/internal/api/grpc/instance/v2/domain.go @@ -0,0 +1,62 @@ +package instance + +import ( + "context" + + "connectrpc.com/connect" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/pkg/grpc/instance/v2" +) + +func (s *Server) AddCustomDomain(ctx context.Context, req *connect.Request[instance.AddCustomDomainRequest]) (*connect.Response[instance.AddCustomDomainResponse], error) { + // Adding a custom domain is currently only allowed with system permissions, + // so we directly check for them in the auth interceptor and do not check here again. + details, err := s.command.AddInstanceDomain(ctx, req.Msg.GetCustomDomain()) + if err != nil { + return nil, err + } + return connect.NewResponse(&instance.AddCustomDomainResponse{ + CreationDate: timestamppb.New(details.EventDate), + }), nil +} + +func (s *Server) RemoveCustomDomain(ctx context.Context, req *connect.Request[instance.RemoveCustomDomainRequest]) (*connect.Response[instance.RemoveCustomDomainResponse], error) { + // Removing a custom domain is currently only allowed with system permissions, + // so we directly check for them in the auth interceptor and do not check here again. + details, err := s.command.RemoveInstanceDomain(ctx, req.Msg.GetCustomDomain()) + if err != nil { + return nil, err + } + return connect.NewResponse(&instance.RemoveCustomDomainResponse{ + DeletionDate: timestamppb.New(details.EventDate), + }), nil +} + +func (s *Server) AddTrustedDomain(ctx context.Context, req *connect.Request[instance.AddTrustedDomainRequest]) (*connect.Response[instance.AddTrustedDomainResponse], error) { + if err := s.checkPermission(ctx, domain.PermissionSystemInstanceWrite, domain.PermissionInstanceWrite); err != nil { + return nil, err + } + details, err := s.command.AddTrustedDomain(ctx, req.Msg.GetTrustedDomain()) + if err != nil { + return nil, err + } + return connect.NewResponse(&instance.AddTrustedDomainResponse{ + CreationDate: timestamppb.New(details.EventDate), + }), nil +} + +func (s *Server) RemoveTrustedDomain(ctx context.Context, req *connect.Request[instance.RemoveTrustedDomainRequest]) (*connect.Response[instance.RemoveTrustedDomainResponse], error) { + if err := s.checkPermission(ctx, domain.PermissionSystemInstanceWrite, domain.PermissionInstanceWrite); err != nil { + return nil, err + } + details, err := s.command.RemoveTrustedDomain(ctx, req.Msg.GetTrustedDomain()) + if err != nil { + return nil, err + } + + return connect.NewResponse(&instance.RemoveTrustedDomainResponse{ + DeletionDate: timestamppb.New(details.EventDate), + }), nil +} diff --git a/internal/api/grpc/instance/v2/instance.go b/internal/api/grpc/instance/v2/instance.go new file mode 100644 index 000000000..cead58ed8 --- /dev/null +++ b/internal/api/grpc/instance/v2/instance.go @@ -0,0 +1,39 @@ +package instance + +import ( + "context" + + "connectrpc.com/connect" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/pkg/grpc/instance/v2" +) + +func (s *Server) DeleteInstance(ctx context.Context, request *connect.Request[instance.DeleteInstanceRequest]) (*connect.Response[instance.DeleteInstanceResponse], error) { + // Deleting an instance is currently only allowed with system permissions, + // so we directly check for them in the auth interceptor and do not check here again. + obj, err := s.command.RemoveInstance(ctx, request.Msg.GetInstanceId()) + if err != nil { + return nil, err + } + + return connect.NewResponse(&instance.DeleteInstanceResponse{ + DeletionDate: timestamppb.New(obj.EventDate), + }), nil + +} + +func (s *Server) UpdateInstance(ctx context.Context, request *connect.Request[instance.UpdateInstanceRequest]) (*connect.Response[instance.UpdateInstanceResponse], error) { + if err := s.checkPermission(ctx, domain.PermissionSystemInstanceWrite, domain.PermissionInstanceWrite); err != nil { + return nil, err + } + obj, err := s.command.UpdateInstance(ctx, request.Msg.GetInstanceName()) + if err != nil { + return nil, err + } + + return connect.NewResponse(&instance.UpdateInstanceResponse{ + ChangeDate: timestamppb.New(obj.EventDate), + }), nil +} diff --git a/internal/api/grpc/instance/v2/integration_test/domain_test.go b/internal/api/grpc/instance/v2/integration_test/domain_test.go new file mode 100644 index 000000000..09cdeb49b --- /dev/null +++ b/internal/api/grpc/instance/v2/integration_test/domain_test.go @@ -0,0 +1,349 @@ +//go:build integration + +package instance_test + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/zitadel/zitadel/internal/integration" + "github.com/zitadel/zitadel/pkg/grpc/instance/v2" +) + +func TestAddCustomDomain(t *testing.T) { + t.Parallel() + + // Given + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + ctxWithSysAuthZ := integration.WithSystemAuthorization(ctx) + + inst := integration.NewInstance(ctxWithSysAuthZ) + iamOwnerCtx := inst.WithAuthorizationToken(context.Background(), integration.UserTypeIAMOwner) + + t.Cleanup(func() { + inst.Client.InstanceV2.DeleteInstance(ctxWithSysAuthZ, &instance.DeleteInstanceRequest{InstanceId: inst.ID()}) + }) + + tt := []struct { + testName string + inputContext context.Context + inputRequest *instance.AddCustomDomainRequest + expectedErrorMsg string + expectedErrorCode codes.Code + }{ + { + testName: "when invalid context should return unauthN error", + inputRequest: &instance.AddCustomDomainRequest{ + InstanceId: inst.ID(), + CustomDomain: integration.DomainName(), + }, + inputContext: context.Background(), + expectedErrorCode: codes.Unauthenticated, + expectedErrorMsg: "auth header missing", + }, + { + testName: "when unauthZ context should return unauthZ error", + inputRequest: &instance.AddCustomDomainRequest{ + InstanceId: inst.ID(), + CustomDomain: integration.DomainName(), + }, + inputContext: iamOwnerCtx, + expectedErrorCode: codes.PermissionDenied, + expectedErrorMsg: "No matching permissions found (AUTH-5mWD2)", + }, + { + testName: "when invalid domain should return invalid argument error", + inputRequest: &instance.AddCustomDomainRequest{ + InstanceId: inst.ID(), + CustomDomain: " ", + }, + inputContext: ctxWithSysAuthZ, + expectedErrorCode: codes.InvalidArgument, + expectedErrorMsg: "Errors.Invalid.Argument (INST-28nlD)", + }, + { + testName: "when valid request should return successful response", + inputRequest: &instance.AddCustomDomainRequest{ + InstanceId: inst.ID(), + CustomDomain: " " + integration.DomainName(), + }, + inputContext: ctxWithSysAuthZ, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Cleanup(func() { + if tc.expectedErrorMsg == "" { + inst.Client.InstanceV2.RemoveCustomDomain(ctxWithSysAuthZ, &instance.RemoveCustomDomainRequest{CustomDomain: strings.TrimSpace(tc.inputRequest.CustomDomain)}) + } + }) + + // Test + res, err := inst.Client.InstanceV2.AddCustomDomain(tc.inputContext, tc.inputRequest) + + // Verify + assert.Equal(t, tc.expectedErrorCode, status.Code(err)) + assert.Equal(t, tc.expectedErrorMsg, status.Convert(err).Message()) + + if tc.expectedErrorMsg == "" { + assert.NotNil(t, res) + assert.NotEmpty(t, res.GetCreationDate()) + } + }) + } +} + +func TestRemoveCustomDomain(t *testing.T) { + t.Parallel() + + // Given + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + ctxWithSysAuthZ := integration.WithSystemAuthorization(ctx) + inst := integration.NewInstance(ctxWithSysAuthZ) + iamOwnerCtx := inst.WithAuthorizationToken(context.Background(), integration.UserTypeIAMOwner) + + customDomain := integration.DomainName() + + _, err := inst.Client.InstanceV2.AddCustomDomain(ctxWithSysAuthZ, &instance.AddCustomDomainRequest{InstanceId: inst.ID(), CustomDomain: customDomain}) + require.Nil(t, err) + + t.Cleanup(func() { + inst.Client.InstanceV2.RemoveCustomDomain(ctxWithSysAuthZ, &instance.RemoveCustomDomainRequest{InstanceId: inst.ID(), CustomDomain: customDomain}) + inst.Client.InstanceV2.DeleteInstance(ctxWithSysAuthZ, &instance.DeleteInstanceRequest{InstanceId: inst.ID()}) + }) + + tt := []struct { + testName string + inputContext context.Context + inputRequest *instance.RemoveCustomDomainRequest + expectedErrorMsg string + expectedErrorCode codes.Code + }{ + { + testName: "when invalid context should return unauthN error", + inputRequest: &instance.RemoveCustomDomainRequest{ + InstanceId: inst.ID(), + CustomDomain: "custom1", + }, + inputContext: context.Background(), + expectedErrorCode: codes.Unauthenticated, + expectedErrorMsg: "auth header missing", + }, + { + testName: "when unauthZ context should return unauthZ error", + inputRequest: &instance.RemoveCustomDomainRequest{ + InstanceId: inst.ID(), + CustomDomain: "custom1", + }, + inputContext: iamOwnerCtx, + expectedErrorCode: codes.PermissionDenied, + expectedErrorMsg: "No matching permissions found (AUTH-5mWD2)", + }, + { + testName: "when invalid domain should return invalid argument error", + inputRequest: &instance.RemoveCustomDomainRequest{ + InstanceId: inst.ID(), + CustomDomain: " ", + }, + inputContext: ctxWithSysAuthZ, + expectedErrorCode: codes.InvalidArgument, + expectedErrorMsg: "Errors.Invalid.Argument (INST-39nls)", + }, + { + testName: "when valid request should return successful response", + inputRequest: &instance.RemoveCustomDomainRequest{ + InstanceId: inst.ID(), + CustomDomain: " " + customDomain, + }, + inputContext: ctxWithSysAuthZ, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + // Test + res, err := inst.Client.InstanceV2.RemoveCustomDomain(tc.inputContext, tc.inputRequest) + + // Verify + assert.Equal(t, tc.expectedErrorCode, status.Code(err)) + assert.Equal(t, tc.expectedErrorMsg, status.Convert(err).Message()) + + if tc.expectedErrorMsg == "" { + assert.NotNil(t, res) + assert.NotEmpty(t, res.GetDeletionDate()) + } + }) + } +} + +func TestAddTrustedDomain(t *testing.T) { + t.Parallel() + + // Given + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + ctxWithSysAuthZ := integration.WithSystemAuthorization(ctx) + inst := integration.NewInstance(ctxWithSysAuthZ) + orgOwnerCtx := inst.WithAuthorizationToken(context.Background(), integration.UserTypeOrgOwner) + + t.Cleanup(func() { + inst.Client.InstanceV2.DeleteInstance(ctxWithSysAuthZ, &instance.DeleteInstanceRequest{InstanceId: inst.ID()}) + }) + + tt := []struct { + testName string + inputContext context.Context + inputRequest *instance.AddTrustedDomainRequest + expectedErrorMsg string + expectedErrorCode codes.Code + }{ + { + testName: "when invalid context should return unauthN error", + inputRequest: &instance.AddTrustedDomainRequest{ + InstanceId: inst.ID(), + TrustedDomain: "trusted1", + }, + inputContext: context.Background(), + expectedErrorCode: codes.Unauthenticated, + expectedErrorMsg: "auth header missing", + }, + { + testName: "when unauthZ context should return unauthZ error", + inputRequest: &instance.AddTrustedDomainRequest{ + InstanceId: inst.ID(), + TrustedDomain: "trusted1", + }, + inputContext: orgOwnerCtx, + expectedErrorCode: codes.NotFound, + expectedErrorMsg: "membership not found (AUTHZ-cdgFk)", + }, + { + testName: "when invalid domain should return invalid argument error", + inputRequest: &instance.AddTrustedDomainRequest{ + InstanceId: inst.ID(), + TrustedDomain: " ", + }, + inputContext: ctxWithSysAuthZ, + expectedErrorCode: codes.InvalidArgument, + expectedErrorMsg: "Errors.Invalid.Argument (COMMA-Stk21)", + }, + { + testName: "when valid request should return successful response", + inputRequest: &instance.AddTrustedDomainRequest{ + InstanceId: inst.ID(), + TrustedDomain: " " + integration.DomainName(), + }, + inputContext: ctxWithSysAuthZ, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Cleanup(func() { + if tc.expectedErrorMsg == "" { + inst.Client.InstanceV2.RemoveTrustedDomain(ctxWithSysAuthZ, &instance.RemoveTrustedDomainRequest{TrustedDomain: strings.TrimSpace(tc.inputRequest.TrustedDomain)}) + } + }) + + // Test + res, err := inst.Client.InstanceV2.AddTrustedDomain(tc.inputContext, tc.inputRequest) + + // Verify + assert.Equal(t, tc.expectedErrorCode, status.Code(err)) + assert.Equal(t, tc.expectedErrorMsg, status.Convert(err).Message()) + + if tc.expectedErrorMsg == "" { + assert.NotNil(t, res) + assert.NotEmpty(t, res.GetCreationDate()) + } + }) + } +} + +func TestRemoveTrustedDomain(t *testing.T) { + t.Parallel() + + // Given + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + ctxWithSysAuthZ := integration.WithSystemAuthorization(ctx) + inst := integration.NewInstance(ctxWithSysAuthZ) + orgOwnerCtx := inst.WithAuthorizationToken(context.Background(), integration.UserTypeOrgOwner) + + trustedDomain := integration.DomainName() + + _, err := inst.Client.InstanceV2.AddTrustedDomain(ctxWithSysAuthZ, &instance.AddTrustedDomainRequest{InstanceId: inst.ID(), TrustedDomain: trustedDomain}) + require.Nil(t, err) + + t.Cleanup(func() { + inst.Client.InstanceV2.RemoveTrustedDomain(ctxWithSysAuthZ, &instance.RemoveTrustedDomainRequest{InstanceId: inst.ID(), TrustedDomain: trustedDomain}) + inst.Client.InstanceV2.DeleteInstance(ctxWithSysAuthZ, &instance.DeleteInstanceRequest{InstanceId: inst.ID()}) + }) + + tt := []struct { + testName string + inputContext context.Context + inputRequest *instance.RemoveTrustedDomainRequest + expectedErrorMsg string + expectedErrorCode codes.Code + }{ + { + testName: "when invalid context should return unauthN error", + inputRequest: &instance.RemoveTrustedDomainRequest{ + InstanceId: inst.ID(), + TrustedDomain: "trusted1", + }, + inputContext: context.Background(), + expectedErrorCode: codes.Unauthenticated, + expectedErrorMsg: "auth header missing", + }, + { + testName: "when unauthZ context should return unauthZ error", + inputRequest: &instance.RemoveTrustedDomainRequest{ + InstanceId: inst.ID(), + TrustedDomain: "trusted1", + }, + inputContext: orgOwnerCtx, + expectedErrorCode: codes.NotFound, + expectedErrorMsg: "membership not found (AUTHZ-cdgFk)", + }, + { + testName: "when valid request should return successful response", + inputRequest: &instance.RemoveTrustedDomainRequest{ + InstanceId: inst.ID(), + TrustedDomain: " " + trustedDomain, + }, + inputContext: ctxWithSysAuthZ, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + // Test + res, err := inst.Client.InstanceV2.RemoveTrustedDomain(tc.inputContext, tc.inputRequest) + + // Verify + assert.Equal(t, tc.expectedErrorCode, status.Code(err)) + assert.Equal(t, tc.expectedErrorMsg, status.Convert(err).Message()) + + if tc.expectedErrorMsg == "" { + require.NotNil(t, res) + require.NotEmpty(t, res.GetDeletionDate()) + } + }) + } +} diff --git a/internal/api/grpc/instance/v2/integration_test/instance_test.go b/internal/api/grpc/instance/v2/integration_test/instance_test.go new file mode 100644 index 000000000..57477343e --- /dev/null +++ b/internal/api/grpc/instance/v2/integration_test/instance_test.go @@ -0,0 +1,252 @@ +//go:build integration + +package instance_test + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/zitadel/zitadel/internal/integration" + "github.com/zitadel/zitadel/pkg/grpc/instance/v2" +) + +func TestDeleteInstance(t *testing.T) { + t.Parallel() + + // Given + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + ctxWithSysAuthZ := integration.WithSystemAuthorization(ctx) + + inst := integration.NewInstance(ctxWithSysAuthZ) + instanceOwnerCtx := inst.WithAuthorizationToken(context.Background(), integration.UserTypeIAMOwner) + inst2 := integration.NewInstance(ctxWithSysAuthZ) + + t.Cleanup(func() { + inst.Client.InstanceV2.DeleteInstance(ctxWithSysAuthZ, &instance.DeleteInstanceRequest{InstanceId: inst.ID()}) + }) + + tt := []struct { + testName string + inputRequest *instance.DeleteInstanceRequest + inputContext context.Context + expectedErrorMsg string + expectedErrorCode codes.Code + expectedInstanceID string + }{ + { + testName: "when invalid context should return unauthN error", + inputRequest: &instance.DeleteInstanceRequest{ + InstanceId: inst.ID(), + }, + inputContext: context.Background(), + expectedErrorCode: codes.Unauthenticated, + expectedErrorMsg: "auth header missing", + }, + { + testName: "when invalid context and invalid id should return unauthN error", + inputRequest: &instance.DeleteInstanceRequest{ + InstanceId: inst.ID() + "invalid", + }, + inputContext: context.Background(), + expectedErrorCode: codes.Unauthenticated, + expectedErrorMsg: "auth header missing", + }, + { + testName: "when instance token for invalid instance should return unauthN error", + inputRequest: &instance.DeleteInstanceRequest{ + InstanceId: inst.ID() + "invalid", + }, + inputContext: instanceOwnerCtx, + expectedErrorCode: codes.Unauthenticated, + expectedErrorMsg: "Errors.Token.Invalid (AUTH-7fs1e)", + }, + { + testName: "when instance token for other instance should return unauthN error", + inputRequest: &instance.DeleteInstanceRequest{ + InstanceId: inst2.ID(), + }, + inputContext: instanceOwnerCtx, + expectedErrorCode: codes.Unauthenticated, + expectedErrorMsg: "Errors.Token.Invalid (AUTH-7fs1e)", + }, + { + testName: "when instance token for own instance should return unauthZ error", + inputRequest: &instance.DeleteInstanceRequest{ + InstanceId: inst.ID(), + }, + inputContext: instanceOwnerCtx, + expectedErrorCode: codes.PermissionDenied, + expectedErrorMsg: "No matching permissions found (AUTH-5mWD2)", + }, + { + testName: "when invalid id should return not found error", + inputRequest: &instance.DeleteInstanceRequest{ + InstanceId: inst.ID() + "invalid", + }, + inputContext: ctxWithSysAuthZ, + expectedErrorCode: codes.NotFound, + expectedErrorMsg: "Errors.Instance.NotFound (COMMA-AE3GS)", + }, + { + testName: "when delete succeeds should return deletion date", + inputRequest: &instance.DeleteInstanceRequest{ + InstanceId: inst.ID(), + }, + inputContext: ctxWithSysAuthZ, + expectedInstanceID: inst.ID(), + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + // Test + res, err := inst.Client.InstanceV2.DeleteInstance(tc.inputContext, tc.inputRequest) + + // Verify + assert.Equal(t, tc.expectedErrorCode, status.Code(err)) + assert.Equal(t, tc.expectedErrorMsg, status.Convert(err).Message()) + if tc.expectedErrorMsg == "" { + require.NotNil(t, res) + require.NotEmpty(t, res.GetDeletionDate()) + } + }) + } +} + +func TestUpdateInstance(t *testing.T) { + t.Parallel() + + // Given + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + ctxWithSysAuthZ := integration.WithSystemAuthorization(ctx) + + inst := integration.NewInstance(ctxWithSysAuthZ) + instanceOwnerCtx := inst.WithAuthorizationToken(context.Background(), integration.UserTypeIAMOwner) + orgOwnerCtx := inst.WithAuthorizationToken(context.Background(), integration.UserTypeOrgOwner) + inst2 := integration.NewInstance(ctxWithSysAuthZ) + + t.Cleanup(func() { + inst.Client.InstanceV2.DeleteInstance(ctxWithSysAuthZ, &instance.DeleteInstanceRequest{InstanceId: inst.ID()}) + }) + + tt := []struct { + testName string + inputRequest *instance.UpdateInstanceRequest + inputContext context.Context + expectedErrorMsg string + expectedErrorCode codes.Code + expectedNewName string + }{ + { + testName: "when invalid context should return unauthN error", + inputRequest: &instance.UpdateInstanceRequest{ + InstanceId: inst.ID(), + InstanceName: " ", + }, + inputContext: context.Background(), + expectedErrorCode: codes.Unauthenticated, + expectedErrorMsg: "auth header missing", + }, + { + testName: "when invalid context and invalid id should return unauthN error", + inputRequest: &instance.UpdateInstanceRequest{ + InstanceId: inst.ID() + "invalid", + InstanceName: " ", + }, + inputContext: context.Background(), + expectedErrorCode: codes.Unauthenticated, + expectedErrorMsg: "auth header missing", + }, + { + testName: "when instance token for invalid instance should return unauthN error", + inputRequest: &instance.UpdateInstanceRequest{ + InstanceId: inst.ID() + "invalid", + InstanceName: " ", + }, + inputContext: instanceOwnerCtx, + expectedErrorCode: codes.Unauthenticated, + expectedErrorMsg: "Errors.Token.Invalid (AUTH-7fs1e)", + }, + { + testName: "when instance token for other instance should return unauthN error", + inputRequest: &instance.UpdateInstanceRequest{ + InstanceId: inst2.ID(), + InstanceName: " ", + }, + inputContext: instanceOwnerCtx, + expectedErrorCode: codes.Unauthenticated, + expectedErrorMsg: "Errors.Token.Invalid (AUTH-7fs1e)", + }, + { + testName: "when unauthZ context should return unauthZ error", + inputRequest: &instance.UpdateInstanceRequest{ + InstanceId: inst.ID(), + InstanceName: " ", + }, + inputContext: orgOwnerCtx, + expectedErrorCode: codes.NotFound, + expectedErrorMsg: "membership not found (AUTHZ-cdgFk)", + }, + { + testName: "when invalid id should return not found error", + inputRequest: &instance.UpdateInstanceRequest{ + InstanceId: inst.ID() + "invalid", + InstanceName: "an-updated-name", + }, + inputContext: ctxWithSysAuthZ, + expectedErrorCode: codes.NotFound, + expectedErrorMsg: "Errors.Instance.NotFound (INST-nuso2m)", + }, + { + testName: "when update succeeds (instance owner) should change instance name", + inputRequest: &instance.UpdateInstanceRequest{ + InstanceId: inst.ID(), + InstanceName: "an-updated-name", + }, + inputContext: instanceOwnerCtx, + expectedNewName: "an-updated-name", + }, + { + testName: "when update succeeds should change instance name", + inputRequest: &instance.UpdateInstanceRequest{ + InstanceId: inst2.ID(), + InstanceName: "an-updated-name2", + }, + inputContext: ctxWithSysAuthZ, + expectedNewName: "an-updated-name2", + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + // Test + res, err := inst.Client.InstanceV2.UpdateInstance(tc.inputContext, tc.inputRequest) + + // Verify + assert.Equal(t, tc.expectedErrorCode, status.Code(err)) + assert.Equal(t, tc.expectedErrorMsg, status.Convert(err).Message()) + if tc.expectedErrorMsg == "" { + + require.NotNil(t, res) + assert.NotEmpty(t, res.GetChangeDate()) + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(tc.inputContext, time.Minute) + require.EventuallyWithT(t, func(tt *assert.CollectT) { + retrievedInstance, err := inst.Client.InstanceV2.GetInstance(tc.inputContext, &instance.GetInstanceRequest{InstanceId: tc.inputRequest.GetInstanceId()}) + require.Nil(tt, err) + assert.Equal(tt, tc.expectedNewName, retrievedInstance.GetInstance().GetName()) + }, retryDuration, tick, "timeout waiting for expected execution result") + } + }) + } +} diff --git a/internal/api/grpc/instance/v2/integration_test/query_test.go b/internal/api/grpc/instance/v2/integration_test/query_test.go new file mode 100644 index 000000000..87bb37bdd --- /dev/null +++ b/internal/api/grpc/instance/v2/integration_test/query_test.go @@ -0,0 +1,427 @@ +//go:build integration + +package instance_test + +import ( + "context" + "slices" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/zitadel/zitadel/internal/integration" + "github.com/zitadel/zitadel/pkg/grpc/filter/v2" + "github.com/zitadel/zitadel/pkg/grpc/instance/v2" + "github.com/zitadel/zitadel/pkg/grpc/object/v2" +) + +func TestGetInstance(t *testing.T) { + t.Parallel() + + // Given + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + ctxWithSysAuthZ := integration.WithSystemAuthorization(ctx) + inst := integration.NewInstance(ctxWithSysAuthZ) + instanceOwnerCtx := inst.WithAuthorizationToken(context.Background(), integration.UserTypeIAMOwner) + organizationOwnerCtx := inst.WithAuthorizationToken(context.Background(), integration.UserTypeOrgOwner) + + t.Cleanup(func() { + inst.Client.InstanceV2.DeleteInstance(ctxWithSysAuthZ, &instance.DeleteInstanceRequest{InstanceId: inst.ID()}) + }) + + tt := []struct { + testName string + inputContext context.Context + inputInstanceID string + expectedInstanceID string + expectedErrorMsg string + expectedErrorCode codes.Code + }{ + { + testName: "when unauthN context should return unauthN error", + inputContext: context.Background(), + inputInstanceID: inst.ID(), + expectedErrorCode: codes.Unauthenticated, + expectedErrorMsg: "auth header missing", + }, + { + testName: "when unauthZ context should return unauthZ error", + inputContext: organizationOwnerCtx, + inputInstanceID: inst.ID(), + expectedErrorCode: codes.NotFound, + expectedErrorMsg: "membership not found (AUTHZ-cdgFk)", + }, + { + testName: "when request succeeds should return matching instance (systemUser)", + inputContext: ctxWithSysAuthZ, + inputInstanceID: inst.ID(), + expectedInstanceID: inst.ID(), + }, + { + testName: "when request succeeds should return matching instance (own context)", + inputContext: instanceOwnerCtx, + expectedInstanceID: inst.ID(), + }, + { + testName: "when instance not found should return not found error", + inputContext: ctxWithSysAuthZ, + inputInstanceID: "invalid", + expectedErrorCode: codes.NotFound, + expectedErrorMsg: "Errors.IAM.NotFound (QUERY-n0wng)", + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + // Test + res, err := inst.Client.InstanceV2.GetInstance(tc.inputContext, &instance.GetInstanceRequest{InstanceId: tc.inputInstanceID}) + + // Verify + assert.Equal(t, tc.expectedErrorCode, status.Code(err)) + assert.Equal(t, tc.expectedErrorMsg, status.Convert(err).Message()) + + if tc.expectedErrorMsg == "" { + require.NoError(t, err) + assert.Equal(t, tc.expectedInstanceID, res.GetInstance().GetId()) + } + }) + } +} + +func TestListInstances(t *testing.T) { + t.Parallel() + + // Given + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + ctxWithSysAuthZ := integration.WithSystemAuthorization(ctx) + + instances := make([]*integration.Instance, 2) + inst := integration.NewInstance(ctxWithSysAuthZ) + inst2 := integration.NewInstance(ctxWithSysAuthZ) + instances[0], instances[1] = inst, inst2 + + t.Cleanup(func() { + inst.Client.InstanceV2.DeleteInstance(ctxWithSysAuthZ, &instance.DeleteInstanceRequest{InstanceId: inst.ID()}) + inst.Client.InstanceV2.DeleteInstance(ctxWithSysAuthZ, &instance.DeleteInstanceRequest{InstanceId: inst2.ID()}) + }) + + // Sort in descending order + slices.SortFunc(instances, func(i1, i2 *integration.Instance) int { + res := i1.Instance.Details.CreationDate.AsTime().Compare(i2.Instance.Details.CreationDate.AsTime()) + if res == 0 { + return res + } + return -res + }) + + orgOwnerCtx := inst.WithAuthorizationToken(context.Background(), integration.UserTypeOrgOwner) + + tt := []struct { + testName string + inputRequest *instance.ListInstancesRequest + inputContext context.Context + expectedErrorMsg string + expectedErrorCode codes.Code + expectedInstances []string + }{ + { + testName: "when invalid context should return unauthN error", + inputRequest: &instance.ListInstancesRequest{ + Pagination: &filter.PaginationRequest{Offset: 0, Limit: 10}, + }, + inputContext: context.Background(), + expectedErrorCode: codes.Unauthenticated, + expectedErrorMsg: "auth header missing", + }, + { + testName: "when unauthZ context should return unauthZ error", + inputRequest: &instance.ListInstancesRequest{ + Pagination: &filter.PaginationRequest{Offset: 0, Limit: 10}, + }, + inputContext: orgOwnerCtx, + expectedErrorCode: codes.Unauthenticated, + expectedErrorMsg: "Errors.Token.Invalid (AUTH-7fs1e)", + }, + { + testName: "when valid request with filter should return paginated response", + inputRequest: &instance.ListInstancesRequest{ + Pagination: &filter.PaginationRequest{Offset: 0, Limit: 10}, + SortingColumn: instance.FieldName_FIELD_NAME_CREATION_DATE, + Filters: []*instance.Filter{ + { + Filter: &instance.Filter_InIdsFilter{ + InIdsFilter: &filter.InIDsFilter{ + Ids: []string{inst.ID(), inst2.ID()}, + }, + }, + }, + }, + }, + inputContext: ctxWithSysAuthZ, + expectedInstances: []string{inst2.ID(), inst.ID()}, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + // Test + res, err := inst.Client.InstanceV2.ListInstances(tc.inputContext, tc.inputRequest) + + // Verify + assert.Equal(t, tc.expectedErrorCode, status.Code(err)) + assert.Equal(t, tc.expectedErrorMsg, status.Convert(err).Message()) + + if tc.expectedErrorMsg == "" { + require.NotNil(t, res) + + require.Len(t, res.GetInstances(), len(tc.expectedInstances)) + + for i, ins := range res.GetInstances() { + assert.Equal(t, tc.expectedInstances[i], ins.GetId()) + } + } + }) + } +} + +func TestListCustomDomains(t *testing.T) { + t.Parallel() + + // Given + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + ctxWithSysAuthZ := integration.WithSystemAuthorization(ctx) + inst := integration.NewInstance(ctxWithSysAuthZ) + + orgOwnerCtx := inst.WithAuthorizationToken(context.Background(), integration.UserTypeOrgOwner) + instanceOwnerCtx := inst.WithAuthorizationToken(context.Background(), integration.UserTypeIAMOwner) + d1, d2 := "custom."+integration.DomainName(), "custom."+integration.DomainName() + + _, err := inst.Client.InstanceV2.AddCustomDomain(ctxWithSysAuthZ, &instance.AddCustomDomainRequest{InstanceId: inst.ID(), CustomDomain: d1}) + require.Nil(t, err) + _, err = inst.Client.InstanceV2.AddCustomDomain(ctxWithSysAuthZ, &instance.AddCustomDomainRequest{InstanceId: inst.ID(), CustomDomain: d2}) + require.Nil(t, err) + + t.Cleanup(func() { + inst.Client.InstanceV2.RemoveCustomDomain(ctxWithSysAuthZ, &instance.RemoveCustomDomainRequest{InstanceId: inst.ID(), CustomDomain: d1}) + inst.Client.InstanceV2.RemoveCustomDomain(ctxWithSysAuthZ, &instance.RemoveCustomDomainRequest{InstanceId: inst.ID(), CustomDomain: d2}) + inst.Client.InstanceV2.DeleteInstance(ctxWithSysAuthZ, &instance.DeleteInstanceRequest{InstanceId: inst.ID()}) + }) + + tt := []struct { + testName string + inputRequest *instance.ListCustomDomainsRequest + inputContext context.Context + expectedErrorMsg string + expectedErrorCode codes.Code + expectedDomains []string + }{ + { + testName: "when invalid context should return unauthN error", + inputRequest: &instance.ListCustomDomainsRequest{ + InstanceId: inst.ID(), + Pagination: &filter.PaginationRequest{Offset: 0, Limit: 10}, + }, + inputContext: context.Background(), + expectedErrorCode: codes.Unauthenticated, + expectedErrorMsg: "auth header missing", + }, + { + testName: "when unauthZ context should return unauthZ error", + inputRequest: &instance.ListCustomDomainsRequest{ + InstanceId: inst.ID(), + Pagination: &filter.PaginationRequest{Offset: 0, Limit: 10}, + SortingColumn: instance.DomainFieldName_DOMAIN_FIELD_NAME_CREATION_DATE, + Filters: []*instance.CustomDomainFilter{ + { + Filter: &instance.CustomDomainFilter_DomainFilter{ + DomainFilter: &instance.DomainFilter{Domain: "custom", Method: object.TextQueryMethod_TEXT_QUERY_METHOD_CONTAINS}, + }, + }, + }, + }, + inputContext: orgOwnerCtx, + expectedErrorCode: codes.NotFound, + expectedErrorMsg: "membership not found (AUTHZ-cdgFk)", + }, + { + testName: "when valid request with filter should return paginated response (systemUser)", + inputRequest: &instance.ListCustomDomainsRequest{ + InstanceId: inst.ID(), + Pagination: &filter.PaginationRequest{Offset: 0, Limit: 10}, + SortingColumn: instance.DomainFieldName_DOMAIN_FIELD_NAME_CREATION_DATE, + Filters: []*instance.CustomDomainFilter{ + { + Filter: &instance.CustomDomainFilter_DomainFilter{ + DomainFilter: &instance.DomainFilter{Domain: "custom", Method: object.TextQueryMethod_TEXT_QUERY_METHOD_CONTAINS}, + }, + }, + }, + }, + inputContext: ctxWithSysAuthZ, + expectedDomains: []string{d1, d2}, + }, + { + testName: "when valid request with filter should return paginated response (own context)", + inputRequest: &instance.ListCustomDomainsRequest{ + Pagination: &filter.PaginationRequest{Offset: 0, Limit: 10}, + SortingColumn: instance.DomainFieldName_DOMAIN_FIELD_NAME_CREATION_DATE, + Filters: []*instance.CustomDomainFilter{ + { + Filter: &instance.CustomDomainFilter_DomainFilter{ + DomainFilter: &instance.DomainFilter{Domain: "custom", Method: object.TextQueryMethod_TEXT_QUERY_METHOD_CONTAINS}, + }, + }, + }, + }, + inputContext: instanceOwnerCtx, + expectedDomains: []string{d1}, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(tc.inputContext, time.Minute) + require.EventuallyWithT(t, func(collect *assert.CollectT) { + // Test + res, err := inst.Client.InstanceV2.ListCustomDomains(tc.inputContext, tc.inputRequest) + + // Verify + assert.Equal(collect, tc.expectedErrorCode, status.Code(err)) + assert.Equal(collect, tc.expectedErrorMsg, status.Convert(err).Message()) + + if tc.expectedErrorMsg == "" { + domains := []string{} + for _, d := range res.GetDomains() { + domains = append(domains, d.GetDomain()) + } + + assert.Subset(collect, domains, tc.expectedDomains) + } + }, retryDuration, tick) + }) + } +} + +func TestListTrustedDomains(t *testing.T) { + t.Parallel() + + // Given + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + ctxWithSysAuthZ := integration.WithSystemAuthorization(ctx) + inst := integration.NewInstance(ctxWithSysAuthZ) + + orgOwnerCtx := inst.WithAuthorizationToken(context.Background(), integration.UserTypeOrgOwner) + instanceOwnerCtx := inst.WithAuthorizationToken(context.Background(), integration.UserTypeIAMOwner) + d1, d2 := "trusted."+integration.DomainName(), "trusted."+integration.DomainName() + + _, err := inst.Client.InstanceV2.AddTrustedDomain(ctxWithSysAuthZ, &instance.AddTrustedDomainRequest{InstanceId: inst.ID(), TrustedDomain: d1}) + require.Nil(t, err) + _, err = inst.Client.InstanceV2.AddTrustedDomain(ctxWithSysAuthZ, &instance.AddTrustedDomainRequest{InstanceId: inst.ID(), TrustedDomain: d2}) + require.Nil(t, err) + + t.Cleanup(func() { + inst.Client.InstanceV2.RemoveTrustedDomain(ctxWithSysAuthZ, &instance.RemoveTrustedDomainRequest{InstanceId: inst.ID(), TrustedDomain: d1}) + inst.Client.InstanceV2.RemoveTrustedDomain(ctxWithSysAuthZ, &instance.RemoveTrustedDomainRequest{InstanceId: inst.ID(), TrustedDomain: d2}) + inst.Client.InstanceV2.DeleteInstance(ctxWithSysAuthZ, &instance.DeleteInstanceRequest{InstanceId: inst.ID()}) + }) + + tt := []struct { + testName string + inputRequest *instance.ListTrustedDomainsRequest + inputContext context.Context + expectedErrorMsg string + expectedErrorCode codes.Code + expectedDomains []string + }{ + { + testName: "when invalid context should return unauthN error", + inputRequest: &instance.ListTrustedDomainsRequest{ + InstanceId: inst.ID(), + Pagination: &filter.PaginationRequest{Offset: 0, Limit: 10}, + }, + inputContext: context.Background(), + expectedErrorCode: codes.Unauthenticated, + expectedErrorMsg: "auth header missing", + }, + { + testName: "when unauthZ context should return unauthZ error", + inputRequest: &instance.ListTrustedDomainsRequest{ + InstanceId: inst.ID(), + Pagination: &filter.PaginationRequest{Offset: 0, Limit: 10}, + }, + inputContext: orgOwnerCtx, + expectedErrorCode: codes.NotFound, + expectedErrorMsg: "membership not found (AUTHZ-cdgFk)", + }, + { + testName: "when valid request with filter should return paginated response (systemUser)", + inputRequest: &instance.ListTrustedDomainsRequest{ + InstanceId: inst.ID(), + Pagination: &filter.PaginationRequest{Offset: 0, Limit: 10}, + SortingColumn: instance.TrustedDomainFieldName_TRUSTED_DOMAIN_FIELD_NAME_CREATION_DATE, + Filters: []*instance.TrustedDomainFilter{ + { + Filter: &instance.TrustedDomainFilter_DomainFilter{ + DomainFilter: &instance.DomainFilter{Domain: "trusted", Method: object.TextQueryMethod_TEXT_QUERY_METHOD_CONTAINS}, + }, + }, + }, + }, + inputContext: ctxWithSysAuthZ, + expectedDomains: []string{d1, d2}, + }, + { + testName: "when valid request with filter should return paginated response (own context)", + inputRequest: &instance.ListTrustedDomainsRequest{ + Pagination: &filter.PaginationRequest{Offset: 0, Limit: 10}, + SortingColumn: instance.TrustedDomainFieldName_TRUSTED_DOMAIN_FIELD_NAME_CREATION_DATE, + Filters: []*instance.TrustedDomainFilter{ + { + Filter: &instance.TrustedDomainFilter_DomainFilter{ + DomainFilter: &instance.DomainFilter{Domain: "trusted", Method: object.TextQueryMethod_TEXT_QUERY_METHOD_CONTAINS}, + }, + }, + }, + }, + inputContext: instanceOwnerCtx, + expectedDomains: []string{d1}, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(tc.inputContext, time.Minute) + require.EventuallyWithT(t, func(collect *assert.CollectT) { + // Test + res, err := inst.Client.InstanceV2.ListTrustedDomains(tc.inputContext, tc.inputRequest) + + // Verify + assert.Equal(collect, tc.expectedErrorCode, status.Code(err)) + assert.Equal(collect, tc.expectedErrorMsg, status.Convert(err).Message()) + + if tc.expectedErrorMsg == "" { + require.NotNil(t, res) + + domains := []string{} + for _, d := range res.GetTrustedDomain() { + domains = append(domains, d.GetDomain()) + } + + assert.Subset(collect, domains, tc.expectedDomains) + } + }, retryDuration, tick) + }) + } +} diff --git a/internal/api/grpc/instance/v2/query.go b/internal/api/grpc/instance/v2/query.go new file mode 100644 index 000000000..d764bdc92 --- /dev/null +++ b/internal/api/grpc/instance/v2/query.go @@ -0,0 +1,84 @@ +package instance + +import ( + "context" + + "connectrpc.com/connect" + + "github.com/zitadel/zitadel/internal/api/grpc/filter/v2" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/pkg/grpc/instance/v2" +) + +func (s *Server) GetInstance(ctx context.Context, _ *connect.Request[instance.GetInstanceRequest]) (*connect.Response[instance.GetInstanceResponse], error) { + if err := s.checkPermission(ctx, domain.PermissionSystemInstanceRead, domain.PermissionInstanceRead); err != nil { + return nil, err + } + inst, err := s.query.Instance(ctx, true) + if err != nil { + return nil, err + } + + return connect.NewResponse(&instance.GetInstanceResponse{ + Instance: ToProtoObject(inst), + }), nil +} + +func (s *Server) ListInstances(ctx context.Context, req *connect.Request[instance.ListInstancesRequest]) (*connect.Response[instance.ListInstancesResponse], error) { + // List instances is currently only allowed with system permissions, + // so we directly check for them in the auth interceptor and do not check here again. + queries, err := ListInstancesRequestToModel(req.Msg, s.systemDefaults) + if err != nil { + return nil, err + } + + instances, err := s.query.SearchInstances(ctx, queries) + if err != nil { + return nil, err + } + + return connect.NewResponse(&instance.ListInstancesResponse{ + Instances: InstancesToPb(instances.Instances), + Pagination: filter.QueryToPaginationPb(queries.SearchRequest, instances.SearchResponse), + }), nil +} + +func (s *Server) ListCustomDomains(ctx context.Context, req *connect.Request[instance.ListCustomDomainsRequest]) (*connect.Response[instance.ListCustomDomainsResponse], error) { + if err := s.checkPermission(ctx, domain.PermissionSystemInstanceRead, domain.PermissionInstanceRead); err != nil { + return nil, err + } + queries, err := ListCustomDomainsRequestToModel(req.Msg, s.systemDefaults) + if err != nil { + return nil, err + } + + domains, err := s.query.SearchInstanceDomains(ctx, queries) + if err != nil { + return nil, err + } + + return connect.NewResponse(&instance.ListCustomDomainsResponse{ + Domains: DomainsToPb(domains.Domains), + Pagination: filter.QueryToPaginationPb(queries.SearchRequest, domains.SearchResponse), + }), nil +} + +func (s *Server) ListTrustedDomains(ctx context.Context, req *connect.Request[instance.ListTrustedDomainsRequest]) (*connect.Response[instance.ListTrustedDomainsResponse], error) { + if err := s.checkPermission(ctx, domain.PermissionSystemInstanceRead, domain.PermissionInstanceRead); err != nil { + return nil, err + } + queries, err := ListTrustedDomainsRequestToModel(req.Msg, s.systemDefaults) + if err != nil { + return nil, err + } + + domains, err := s.query.SearchInstanceTrustedDomains(ctx, queries) + if err != nil { + return nil, err + } + + return connect.NewResponse(&instance.ListTrustedDomainsResponse{ + TrustedDomain: trustedDomainsToPb(domains.Domains), + Pagination: filter.QueryToPaginationPb(queries.SearchRequest, domains.SearchResponse), + }), nil +} diff --git a/internal/api/grpc/instance/v2/server.go b/internal/api/grpc/instance/v2/server.go new file mode 100644 index 000000000..afd88c027 --- /dev/null +++ b/internal/api/grpc/instance/v2/server.go @@ -0,0 +1,74 @@ +package instance + +import ( + "context" + "net/http" + + "connectrpc.com/connect" + "google.golang.org/protobuf/reflect/protoreflect" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/config/systemdefaults" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/pkg/grpc/instance/v2" + "github.com/zitadel/zitadel/pkg/grpc/instance/v2/instanceconnect" +) + +var _ instanceconnect.InstanceServiceHandler = (*Server)(nil) + +type Server struct { + command *command.Commands + query *query.Queries + systemDefaults systemdefaults.SystemDefaults + defaultInstance command.InstanceSetup + externalDomain string + permissionCheck domain.PermissionCheck +} + +func CreateServer( + command *command.Commands, + query *query.Queries, + defaultInstance command.InstanceSetup, + externalDomain string, + check domain.PermissionCheck, +) *Server { + return &Server{ + command: command, + query: query, + defaultInstance: defaultInstance, + externalDomain: externalDomain, + permissionCheck: check, + } +} + +func (s *Server) RegisterConnectServer(interceptors ...connect.Interceptor) (string, http.Handler) { + return instanceconnect.NewInstanceServiceHandler(s, connect.WithInterceptors(interceptors...)) +} + +func (s *Server) FileDescriptor() protoreflect.FileDescriptor { + return instance.File_zitadel_instance_v2_instance_service_proto +} + +func (s *Server) AppName() string { + return instance.InstanceService_ServiceDesc.ServiceName +} + +func (s *Server) MethodPrefix() string { + return instance.InstanceService_ServiceDesc.ServiceName +} + +func (s *Server) AuthMethods() authz.MethodMapping { + return instance.InstanceService_AuthMethods +} + +// checkPermission checks if either the system-wide or the instance-specific permission is granted. +func (s *Server) checkPermission(ctx context.Context, systemPermission, instancePermission string) error { + // Let's first check the system permission since it's already resolved into the context. + // If that succeeds, we don't need to resolve the instance permission. + if err := s.permissionCheck(ctx, systemPermission, "", ""); err == nil { + return nil + } + return s.permissionCheck(ctx, instancePermission, "", "") +} diff --git a/internal/api/grpc/server/connect_middleware/instance_interceptor.go b/internal/api/grpc/server/connect_middleware/instance_interceptor.go index 50fe774db..c8f517857 100644 --- a/internal/api/grpc/server/connect_middleware/instance_interceptor.go +++ b/internal/api/grpc/server/connect_middleware/instance_interceptor.go @@ -40,7 +40,10 @@ func setInstance(ctx context.Context, req connect.AnyRequest, handler connect.Un if !ok { return handler(ctx, req) } - return addInstanceByID(interceptorCtx, req, handler, verifier, translator, withInstanceIDProperty.GetInstanceId()) + instanceID := withInstanceIDProperty.GetInstanceId() + if instanceID != "" { + return addInstanceByID(interceptorCtx, req, handler, verifier, translator, instanceID) + } } } explicitInstanceRequest, ok := req.Any().(interface { @@ -61,11 +64,12 @@ func setInstance(ctx context.Context, req connect.AnyRequest, handler connect.Un func addInstanceByID(ctx context.Context, req connect.AnyRequest, handler connect.UnaryFunc, verifier authz.InstanceVerifier, translator *i18n.Translator, id string) (connect.AnyResponse, error) { instance, err := verifier.InstanceByID(ctx, id) if err != nil { - notFoundErr := new(zerrors.ZitadelError) - if errors.As(err, ¬FoundErr) { - notFoundErr.Message = translator.LocalizeFromCtx(ctx, notFoundErr.GetMessage(), nil) - } - return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("unable to set instance using id %s: %w", id, notFoundErr)) + // We do not want to expose whether the instance id was invalid or not + // to prevent leaking information about existing instances. + // In case the user has permission to access the instance, but the instance does not exist, + // the error will be returned by the business logic later on. + logging.WithFields("instanceID", id).WithError(err).Error("unable to set instance by id") + return handler(ctx, req) } return handler(authz.WithInstance(ctx, instance), req) } diff --git a/internal/command/instance.go b/internal/command/instance.go index d0945770a..3841fa67d 100644 --- a/internal/command/instance.go +++ b/internal/command/instance.go @@ -753,17 +753,25 @@ func setupMessageTexts(validations *[]preparation.Validation, setupMessageTexts } func (c *Commands) UpdateInstance(ctx context.Context, name string) (*domain.ObjectDetails, error) { + name = strings.TrimSpace(name) + if name == "" { + return nil, zerrors.ThrowInvalidArgument(nil, "INST-092mid", "Errors.Invalid.Argument") + } instanceAgg := instance.NewAggregate(authz.GetInstance(ctx).InstanceID()) - validation := c.prepareUpdateInstance(instanceAgg, strings.TrimSpace(name)) - cmds, err := preparation.PrepareCommands(ctx, c.eventstore.Filter, validation) + writeModel, err := getInstanceWriteModel(ctx, c.eventstore.Filter) //nolint:staticcheck if err != nil { return nil, err } - events, err := c.eventstore.Push(ctx, cmds...) - if err != nil { + if !writeModel.State.Exists() { + return nil, zerrors.ThrowNotFound(nil, "INST-nuso2m", "Errors.Instance.NotFound") + } + if writeModel.Name == name { + return writeModelToObjectDetails(&writeModel.WriteModel), nil + } + if err := c.pushAppendAndReduce(ctx, writeModel, instance.NewInstanceChangedEvent(ctx, &instanceAgg.Aggregate, name)); err != nil { return nil, err } - return pushedEventsToObjectDetails(events), nil + return writeModelToObjectDetails(&writeModel.WriteModel), nil } func (c *Commands) SetDefaultLanguage(ctx context.Context, defaultLanguage language.Tag) (*domain.ObjectDetails, error) { @@ -897,27 +905,6 @@ func (c *Commands) setIAMProject(ctx context.Context, iamAgg *eventstore.Aggrega return instance.NewIAMProjectSetEvent(ctx, iamAgg, projectID), nil } -func (c *Commands) prepareUpdateInstance(a *instance.Aggregate, name string) preparation.Validation { - return func() (preparation.CreateCommands, error) { - if name == "" { - return nil, zerrors.ThrowInvalidArgument(nil, "INST-092mid", "Errors.Invalid.Argument") - } - return func(ctx context.Context, filter preparation.FilterToQueryReducer) ([]eventstore.Command, error) { - writeModel, err := getInstanceWriteModel(ctx, filter) - if err != nil { - return nil, err - } - if !writeModel.State.Exists() { - return nil, zerrors.ThrowNotFound(nil, "INST-nuso2m", "Errors.Instance.NotFound") - } - if writeModel.Name == name { - return nil, zerrors.ThrowPreconditionFailed(nil, "INST-alpxism", "Errors.Instance.NotChanged") - } - return []eventstore.Command{instance.NewInstanceChangedEvent(ctx, &a.Aggregate, name)}, nil - }, nil - } -} - func (c *Commands) prepareSetDefaultLanguage(a *instance.Aggregate, defaultLanguage language.Tag) preparation.Validation { return func() (preparation.CreateCommands, error) { if err := domain.LanguageIsDefined(defaultLanguage); err != nil { diff --git a/internal/command/instance_test.go b/internal/command/instance_test.go index f63c5f345..933d9ccfd 100644 --- a/internal/command/instance_test.go +++ b/internal/command/instance_test.go @@ -1411,7 +1411,7 @@ func TestCommandSide_setUpInstance(t *testing.T) { func TestCommandSide_UpdateInstance(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(t *testing.T) *eventstore.Eventstore } type args struct { ctx context.Context @@ -1430,9 +1430,7 @@ func TestCommandSide_UpdateInstance(t *testing.T) { { name: "empty name, invalid error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), }, args: args{ ctx: authz.WithInstanceID(context.Background(), "INSTANCE"), @@ -1445,8 +1443,7 @@ func TestCommandSide_UpdateInstance(t *testing.T) { { name: "instance not existing, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), }, @@ -1461,8 +1458,7 @@ func TestCommandSide_UpdateInstance(t *testing.T) { { name: "instance removed, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( instance.NewInstanceAddedEvent( @@ -1492,8 +1488,7 @@ func TestCommandSide_UpdateInstance(t *testing.T) { { name: "no changes, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( instance.NewInstanceAddedEvent( @@ -1510,14 +1505,15 @@ func TestCommandSide_UpdateInstance(t *testing.T) { name: "INSTANCE", }, res: res{ - err: zerrors.IsPreconditionFailed, + want: &domain.ObjectDetails{ + ResourceOwner: "INSTANCE", + }, }, }, { name: "instance change, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusherWithInstanceID( "INSTANCE", @@ -1549,7 +1545,7 @@ func TestCommandSide_UpdateInstance(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), } got, err := r.UpdateInstance(tt.args.ctx, tt.args.name) if tt.res.err == nil { diff --git a/internal/domain/permission.go b/internal/domain/permission.go index 2d8bda192..df4dd396b 100644 --- a/internal/domain/permission.go +++ b/internal/domain/permission.go @@ -70,6 +70,10 @@ const ( PermissionIAMPolicyWrite = "iam.policy.write" PermissionIAMPolicyDelete = "iam.policy.delete" PermissionPolicyRead = "policy.read" + PermissionInstanceRead = "iam.read" + PermissionInstanceWrite = "iam.write" + PermissionSystemInstanceRead = "system.instance.read" + PermissionSystemInstanceWrite = "system.instance.write" PermissionGroupCreate = "group.create" PermissionGroupWrite = "group.write" PermissionGroupRead = "group.read" diff --git a/internal/integration/client.go b/internal/integration/client.go index ffc7a0f21..5b42f822a 100644 --- a/internal/integration/client.go +++ b/internal/integration/client.go @@ -34,7 +34,8 @@ import ( group_v2 "github.com/zitadel/zitadel/pkg/grpc/group/v2" "github.com/zitadel/zitadel/pkg/grpc/idp" idp_pb "github.com/zitadel/zitadel/pkg/grpc/idp/v2" - instance "github.com/zitadel/zitadel/pkg/grpc/instance/v2beta" + instance_v2 "github.com/zitadel/zitadel/pkg/grpc/instance/v2" + instance_v2beta "github.com/zitadel/zitadel/pkg/grpc/instance/v2beta" internal_permission_v2 "github.com/zitadel/zitadel/pkg/grpc/internal_permission/v2" internal_permission_v2beta "github.com/zitadel/zitadel/pkg/grpc/internal_permission/v2beta" mgmt "github.com/zitadel/zitadel/pkg/grpc/management" @@ -88,7 +89,8 @@ type Client struct { SCIM *scim.Client Projectv2Beta project_v2beta.ProjectServiceClient ProjectV2 project_v2.ProjectServiceClient - InstanceV2Beta instance.InstanceServiceClient + InstanceV2Beta instance_v2beta.InstanceServiceClient //nolint:staticcheck // deprecated but still used in tests + InstanceV2 instance_v2.InstanceServiceClient AppV2Beta app_v2beta.AppServiceClient ApplicationV2 application.ApplicationServiceClient InternalPermissionv2Beta internal_permission_v2beta.InternalPermissionServiceClient @@ -137,7 +139,8 @@ func newClient(ctx context.Context, target string) (*Client, error) { SCIM: scim.NewScimClient(target), Projectv2Beta: project_v2beta.NewProjectServiceClient(cc), ProjectV2: project_v2.NewProjectServiceClient(cc), - InstanceV2Beta: instance.NewInstanceServiceClient(cc), + InstanceV2Beta: instance_v2beta.NewInstanceServiceClient(cc), + InstanceV2: instance_v2.NewInstanceServiceClient(cc), AppV2Beta: app_v2beta.NewAppServiceClient(cc), ApplicationV2: application.NewApplicationServiceClient(cc), InternalPermissionv2Beta: internal_permission_v2beta.NewInternalPermissionServiceClient(cc), diff --git a/proto/zitadel/admin.proto b/proto/zitadel/admin.proto index f99768103..f500f9e5f 100644 --- a/proto/zitadel/admin.proto +++ b/proto/zitadel/admin.proto @@ -293,7 +293,7 @@ service AdminService { // Get My Instance // - // Deprecated: use [instance service v2 GetInstance](apis/resources/instance_service_v2/zitadel-instance-v-2-beta-instance-service-get-instance.api.mdx) instead. + // Deprecated: use [instance service v2 GetInstance](apis/resources/instance_service_v2/zitadel-instance-v-2-instance-service-get-instance.api.mdx) instead. // // Returns the details about the current instance such as the name, version, domains, etc. rpc GetMyInstance(GetMyInstanceRequest) returns (GetMyInstanceResponse) { @@ -313,7 +313,7 @@ service AdminService { // List Instance Domains // - // Deprecated: use [instance service v2 GetInstance](apis/resources/instance_service_v2/zitadel-instance-v-2-beta-instance-service-get-instance.api.mdx) instead. + // Deprecated: use [instance service v2 GetInstance](apis/resources/instance_service_v2/zitadel-instance-v-2-instance-service-get-instance.api.mdx) instead. // // Returns a list of domains that are configured for this ZITADEL instance. These domains are the URLs where ZITADEL is running. rpc ListInstanceDomains(ListInstanceDomainsRequest) returns (ListInstanceDomainsResponse) { @@ -333,7 +333,7 @@ service AdminService { // List Instance Trusted Domains // - // Deprecated: use [instance service v2 ListTrustedDomains](apis/resources/instance_service_v2/zitadel-instance-v-2-beta-instance-service-list-trusted-domains.api.mdx) instead. + // Deprecated: use [instance service v2 ListTrustedDomains](apis/resources/instance_service_v2/zitadel-instance-v-2-instance-service-list-trusted-domains.api.mdx) instead. // // Returns a list of domains that are configured for this ZITADEL instance. These domains are trusted to be used as public hosts. rpc ListInstanceTrustedDomains(ListInstanceTrustedDomainsRequest) returns (ListInstanceTrustedDomainsResponse) { @@ -353,7 +353,7 @@ service AdminService { // Add an Instance Trusted Domain // - // Deprecated: use [instance service v2 ListTrustedDomains](apis/resources/instance_service_v2/zitadel-instance-v-2-beta-instance-service-add-trusted-domain.api.mdx) instead. + // Deprecated: use [instance service v2 ListTrustedDomains](apis/resources/instance_service_v2/zitadel-instance-v-2-instance-service-add-trusted-domain.api.mdx) instead. // // Add a domain to the list configured for this ZITADEL instance. These domains are trusted to be used as public hosts. rpc AddInstanceTrustedDomain(AddInstanceTrustedDomainRequest) returns (AddInstanceTrustedDomainResponse) { @@ -374,7 +374,7 @@ service AdminService { // Remove an Instance Trusted Domain // - // Deprecated: use [instance service v2 ListTrustedDomains](apis/resources/instance_service_v2/zitadel-instance-v-2-beta-instance-service-remove-trusted-domain.api.mdx) instead. + // Deprecated: use [instance service v2 ListTrustedDomains](apis/resources/instance_service_v2/zitadel-instance-v-2-instance-service-remove-trusted-domain.api.mdx) instead. // // Removes a domain from the list configured for this ZITADEL instance. These domains are trusted to be used as public hosts. rpc RemoveInstanceTrustedDomain(RemoveInstanceTrustedDomainRequest) returns (RemoveInstanceTrustedDomainResponse) { diff --git a/proto/zitadel/instance/v2/instance.proto b/proto/zitadel/instance/v2/instance.proto new file mode 100644 index 000000000..6f1d44f0d --- /dev/null +++ b/proto/zitadel/instance/v2/instance.proto @@ -0,0 +1,205 @@ +syntax = "proto3"; + +import "protoc-gen-openapiv2/options/annotations.proto"; +import "zitadel/filter/v2/filter.proto"; +import "zitadel/object/v2/object.proto"; +import "validate/validate.proto"; +import "google/protobuf/timestamp.proto"; + +package zitadel.instance.v2; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/instance/v2;instance"; + +message Instance { + // ID is the unique identifier of the instance. + string id = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"69629023906488334\"" + } + ]; + + // ChangeDate is the timestamp when the instance was last changed. + google.protobuf.Timestamp change_date = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; + + // CreationDate is the timestamp when the instance was created. + google.protobuf.Timestamp creation_date = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; + + // State is the current state of the instance. + State state = 4; + + // Name is the display name of the instance. + // This can be changed by the instance administrator. + string name = 5 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"ZITADEL\""; + } + ]; + + // Version of the system the instance is running on. + // This is managed by the system and cannot be changed by the instance administrator. + string version = 6 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"1.0.0\""; + } + ]; + + // CustomDomains are the domains that are assigned to the instance. + // The list includes auto-generated and manually added domains. + // They are all unique across all instances in the system. + // They're used to route requests to the correct instance. + repeated CustomDomain custom_domains = 7; +} + +enum State { + STATE_UNSPECIFIED = 0; + STATE_CREATING = 1; + STATE_RUNNING = 2; + STATE_STOPPING = 3; + STATE_STOPPED = 4; +} + +message CustomDomain { + // InstanceID is the unique identifier of the instance the domain belongs to. + string instance_id = 1; + + // CreationDate is the timestamp when the domain was created. + google.protobuf.Timestamp creation_date = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; + + // Domain is the fully qualified domain name. + // It must be unique across all instances in the system. + string domain = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"zitadel.com\"" + } + ]; + + // Primary states whether this domain is the primary domain of the instance. + // Each instance must have exactly one primary domain. + // The primary domain is used for various purposes and acts as fallback + // in those cases, e.g if no explicit domain is specified. + bool primary = 4; + + // Generate states whether this domain was auto-generated by the system. + // Auto-generated domains follow a specific pattern and are created + // when a new instance is created. + // They cannot be deleted, but the primary domain can be changed + // to a manually added domain. + bool generated = 5; +} + +enum FieldName { + FIELD_NAME_UNSPECIFIED = 0; + FIELD_NAME_ID = 1; + FIELD_NAME_NAME = 2; + FIELD_NAME_CREATION_DATE = 3; +} + +message Filter { + oneof filter { + option (validate.required) = true; + + // Filter for one or more specific instance IDs. + zitadel.filter.v2.InIDsFilter in_ids_filter = 1; + + // Filter for instances that have at least one of the specified custom domains. + CustomDomainsFilter custom_domains_filter = 2; + } +} + +message CustomDomainsFilter { + // The domains to query for. All instances that have at least one of the + // specified domains will be returned. + // A maximum of 20 domains can be specified. + repeated string domains = 1 [ + (validate.rules).repeated = {max_items: 20, items: {string: {min_len: 1, max_len: 253}}}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + max_items: 20; + example: "[\"my-instace.zitadel.cloud\", \"auth.custom.com\"]"; + } + ]; +} +message CustomDomainFilter { + oneof filter { + option (validate.required) = true; + + // Filter for a specific custom domain. + DomainFilter domain_filter = 1; + + // Filter whether the domain is auto-generated. + bool generated_filter = 2; + + // Filter whether the domain is the primary domain of the instance. + bool primary_filter = 3; + } +} + +message DomainFilter { + // The domain to filter for. + string domain = 1 [ + (validate.rules).string = {max_len: 253}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + max_length: 253; + example: "\"zitadel.com\""; + } + ]; + + // The method to use for text comparison. + // If not specified, EQUALS is used. + zitadel.object.v2.TextQueryMethod method = 2 [ + (validate.rules).enum.defined_only = true + ]; +} + +enum DomainFieldName { + DOMAIN_FIELD_NAME_UNSPECIFIED = 0; + DOMAIN_FIELD_NAME_DOMAIN = 1; + DOMAIN_FIELD_NAME_PRIMARY = 2; + DOMAIN_FIELD_NAME_GENERATED = 3; + DOMAIN_FIELD_NAME_CREATION_DATE = 4; +} + +message TrustedDomain { + // InstanceID is the unique identifier of the instance the domain belongs to. + string instance_id = 1; + + // The timestamp when the domain was created. + google.protobuf.Timestamp creation_date = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; + + // Domain is the fully qualified domain name. + string domain = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"zitadel.com\"" + } + ]; +} + +message TrustedDomainFilter { + oneof filter { + option (validate.required) = true; + + // Filter for a specific trusted domain. + DomainFilter domain_filter = 1; + } +} + +enum TrustedDomainFieldName { + TRUSTED_DOMAIN_FIELD_NAME_UNSPECIFIED = 0; + TRUSTED_DOMAIN_FIELD_NAME_DOMAIN = 1; + TRUSTED_DOMAIN_FIELD_NAME_CREATION_DATE = 2; +} diff --git a/proto/zitadel/instance/v2/instance_service.proto b/proto/zitadel/instance/v2/instance_service.proto new file mode 100644 index 000000000..c9d83bf99 --- /dev/null +++ b/proto/zitadel/instance/v2/instance_service.proto @@ -0,0 +1,508 @@ +syntax = "proto3"; + +package zitadel.instance.v2; + +import "validate/validate.proto"; +import "zitadel/object/v2/object.proto"; +import "zitadel/instance/v2/instance.proto"; +import "zitadel/filter/v2/filter.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"; + +// Service to manage instances and their domains. +// The service provides methods to create, update, delete and list instances and their domains. +// +// Contrary to most services in ZITADEL, the instance service allows accessing data not only from +// the current instance, but also from other instances. +service InstanceService { + + // Delete Instance + // + // Deletes an instance with the given ID. + // This method requires system level permissions and cannot be called from an instance context. + // + // Required permissions: + // - `system.instance.delete` + rpc DeleteInstance(DeleteInstanceRequest) returns (DeleteInstanceResponse) { + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "system.instance.delete" + } + }; + } + + // Get Instance + // + // Returns the instance in the current context or by its ID. + // By default the instance will be determined by the context of the request, + // e.g. the host header. + // You can optionally pass an InstanceID to retrieve a specific instance. + // This requires additional permissions. + // + // Required permissions: + // - `iam.read` + // - `system.instance.read` (if InstanceID is set) + rpc GetInstance(GetInstanceRequest) returns (GetInstanceResponse) { + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + } + + // Update Instance + // + // Updates instance's name in the current context or by its ID. + // By default the instance will be determined by the context of the request, + // e.g. the host header. + // You can optionally pass an InstanceID to update a specific instance. + // This requires additional permissions. + // + // Required permissions: + // - `iam.write` + // - `system.instance.write` (if InstanceID is set) + rpc UpdateInstance(UpdateInstanceRequest) returns (UpdateInstanceResponse) { + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + } + + // List Instances + // + // Lists instances matching the given query. + // The query can be used to filter either by instance ID or domain. + // The request is paginated and returns 100 results by default. + // This method requires system level permissions and cannot be called from an instance context. + // + // Required permissions: + // - `system.instance.read` + rpc ListInstances(ListInstancesRequest) returns (ListInstancesResponse) { + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "system.instance.read" + } + }; + } + + // Add Custom Domain + // + // Adds a custom domain to the instance. + // The custom domain must be unique across all instances. + // Once the domain is added, it will be used to route requests to this instance. + // This method requires system level permissions and cannot be called from an instance context. + // + // Required permissions: + // - `system.domain.write` + rpc AddCustomDomain(AddCustomDomainRequest) returns (AddCustomDomainResponse) { + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "system.domain.write" + } + }; + } + + // Remove Custom Domain + // + // Removes a custom domain from the instance. + // Be aware that this will stop routing requests from this domain to the instance and + // might break existing setups or integrations. + // This method requires system level permissions and cannot be called from an instance context. + // + // Required permissions: + // - `system.domain.write` + rpc RemoveCustomDomain(RemoveCustomDomainRequest) returns (RemoveCustomDomainResponse) { + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "system.domain.write" + } + }; + } + + // List Custom Domains + // + // Lists custom domains of the instance. + // + // By default the instance will be determined by the context of the request, + // e.g. the host header. + // You can optionally pass an InstanceID to list the domains of a specific instance. + // This requires additional permissions. + // + // Required permissions: + // - `iam.read` + // - `system.instance.read` (if InstanceID is set) + rpc ListCustomDomains(ListCustomDomainsRequest) returns (ListCustomDomainsResponse) { + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + } + + // Add Trusted Domain + // + // Adds a trusted domain to the instance. + // By default the instance will be determined by the context of the request, + // e.g. the host header. + // You can optionally pass an InstanceID to list the domains of a specific instance. + // This requires additional permissions. + // + // It must be a valid domain name. + // Once the domain is added, it can be used in API responses like OIDC discovery, + // email templates, and more. + // This can be used in cases where the API is accessed through a different domain + // than the instance domain, e.g. proxy setups and custom login UIs. + // Unlike custom domain, trusted domains are not used to route requests to this instance + // and therefore do not need to be uniquely assigned to an instance. + // + // Required permissions: + // - `iam.write` + // - `system.instance.write` (if InstanceID is set) + rpc AddTrustedDomain(AddTrustedDomainRequest) returns (AddTrustedDomainResponse) { + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + } + + // Remove Trusted Domain + // + // Removes a trusted domain from the instance. + // By default the instance will be determined by the context of the request, + // e.g. the host header. + // You can optionally pass an InstanceID to list the domains of a specific instance. + // This requires additional permissions. + // + // Required permissions: + // - `iam.write` + // - `system.instance.write` (if InstanceID is set) + rpc RemoveTrustedDomain(RemoveTrustedDomainRequest) returns (RemoveTrustedDomainResponse) { + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + } + + + // List Trusted Domains + // + // Lists trusted domains of the instance. + // By default the instance will be determined by the context of the request, + // e.g. the host header. + // You can optionally pass an InstanceID to list the domains of a specific instance. + // This requires additional permissions. + // + // Required permissions: + // - `iam.read` + // - `system.instance.read` (if InstanceID is set) + rpc ListTrustedDomains(ListTrustedDomainsRequest) returns (ListTrustedDomainsResponse) { + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + } +} + +message DeleteInstanceRequest { + // InstanceID is the unique ID of the instance to be deleted. + string instance_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"222430354126975533\""; + }, + (google.api.field_behavior) = REQUIRED + ]; +} + +message DeleteInstanceResponse { + // DeletionDate is the timestamp when the instance was deleted. + google.protobuf.Timestamp deletion_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; +} + +message GetInstanceRequest { + // InstanceID is the unique ID of the instance to be retrieved. + // If not set, the instance in the current context (e.g. identified by the host header) will be returned. + // If an ID is set, the caller must have additional permissions. + string instance_id = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"222430354126975533\""; + } + ]; +} + +message GetInstanceResponse { + // The instance matching the instance ID + zitadel.instance.v2.Instance instance = 1; +} + +message UpdateInstanceRequest { + // InstanceID is the unique ID of the instance to be updated. + // If not set, the instance in the current context (e.g. identified by the host header) will be changed. + // If an ID is set, the caller must have additional permissions. + string instance_id = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"222430354126975533\""; + } + ]; + + // InstanceName is the new name of the instance to be set. + string instance_name = 2 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"my instance\""; + }, + (google.api.field_behavior) = REQUIRED + ]; +} + +message UpdateInstanceResponse { + // ChangeDate is the timestamp when the instance was last changed. + // In case the instance was not changed during the call, the previous change date is returned. + google.protobuf.Timestamp change_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; +} + +message ListInstancesRequest { + // Paginate through the results using a limit, offset and sorting. + zitadel.filter.v2.PaginationRequest pagination = 1; + + // The field the result is sorted by. + FieldName sorting_column = 2 [ + (validate.rules).enum = {defined_only: true} + ]; + + // Filter the instances to be returned. + repeated Filter filters = 3; +} + +message ListInstancesResponse { + // The instances matching the query. + repeated Instance instances = 1; + + // Contains the total number of instances matching the query and the applied limit. + zitadel.filter.v2.PaginationResponse pagination = 2; +} + +message AddCustomDomainRequest { + // InstanceID is the unique ID of the instance to which the domain will be added. + string instance_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"222430354126975533\""; + }, + (google.api.field_behavior) = REQUIRED + ]; + + // Custom domain to add to the instance. + // Must be a valid domain name. + // Once the domain is added, it will be used to route requests to this instance. + string custom_domain = 2 [ + (validate.rules).string = {min_len: 1, max_len: 253}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 253; + }, + (google.api.field_behavior) = REQUIRED + ]; +} + +message AddCustomDomainResponse { + // CreationDate is the timestamp when the domain was added. + google.protobuf.Timestamp creation_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; +} + +message RemoveCustomDomainRequest { + // InstanceID is the unique ID of the instance from which the domain will be removed. + string instance_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"222430354126975533\""; + }, + (google.api.field_behavior) = REQUIRED + ]; + + // CustomDomain is the the domain to remove from the instance. + string custom_domain = 2 [ + (validate.rules).string = {min_len: 1, max_len: 253}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 253; + }, + (google.api.field_behavior) = REQUIRED + ]; +} + +message RemoveCustomDomainResponse { + // DeletionDate is the timestamp when the domain was removed. + google.protobuf.Timestamp deletion_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; +} + +message ListCustomDomainsRequest { + // InstanceID is the unique ID of the instance whose domains will be listed. + // If not set, the instance in the current context (e.g. identified by the host header) will be used. + // If an ID is set, the caller must have additional permissions. + string instance_id = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"222430354126975533\""; + } + ]; + + // Paginate and sort the results using a limit, offset and sorting. + zitadel.filter.v2.PaginationRequest pagination = 2; + + // The field the result is sorted by. + DomainFieldName sorting_column = 3; + + // Filter the domains to be returned. + repeated CustomDomainFilter filters = 4; +} + +message ListCustomDomainsResponse { + // The list of custom domains matching the query. + repeated CustomDomain domains = 1; + + // Contains the total number of domains matching the query and the applied limit. + zitadel.filter.v2.PaginationResponse pagination = 2; +} + +message AddTrustedDomainRequest { + // InstanceID is the unique ID of the instance to which the trusted domain will be added. + // If not set, the instance in the current context (e.g. identified by the host header) will be used. + // If an ID is set, the caller must have additional permissions. + string instance_id = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"222430354126975533\""; + } + ]; + + // Trusted domain to be added to the instance. + // Must be a valid domain name. + // Once the domain is added, it can be used in API responses like OIDC discovery, + // email templates, and more. + // This can be used in cases where the API is accessed through a different domain + // than the instance domain, e.g. proxy setups and custom login UIs. + // Unlike custom domains, trusted domains are not used to route requests to this instance + // and therefore do not need to be uniquely assigned to an instance. + string trusted_domain = 2 [ + (validate.rules).string = {min_len: 1, max_len: 253}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"login.example.com\""; + min_length: 1; + max_length: 253; + } + ]; +} + +message AddTrustedDomainResponse { + // CreationDate is the timestamp when the trusted domain was added. + google.protobuf.Timestamp creation_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; +} + +message RemoveTrustedDomainRequest { + // InstanceID is the unique ID of the instance from which the trusted domain will be removed. + // If not set, the instance in the current context (e.g. identified by the host header) will be used. + // If an ID is set, the caller must have additional permissions. + string instance_id = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"222430354126975533\""; + } + ]; + + // The trusted domain to remove from the instance. + string trusted_domain = 2 [ + (validate.rules).string = {min_len: 1, max_len: 253}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"login.example.com\""; + min_length: 1; + max_length: 253; + } + ]; +} + +message RemoveTrustedDomainResponse { + // DeletionDate is the timestamp when the trusted domain was removed. + google.protobuf.Timestamp deletion_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; +} + +message ListTrustedDomainsRequest { + // InstanceID is the unique ID of the instance whose trusted domains will be listed. + // If not set, the instance in the current context (e.g. identified by the host header) will be used. + // If an ID is set, the caller must have additional permissions. + string instance_id = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"222430354126975533\""; + } + ]; + + // Paginate and sort the results using a limit, offset and sorting. + zitadel.filter.v2.PaginationRequest pagination = 2; + + // The field the result is sorted by. + TrustedDomainFieldName sorting_column = 3; + + // Filter the domains to be returned. + repeated TrustedDomainFilter filters = 4; +} + +message ListTrustedDomainsResponse { + // The list of trusted domains matching the query. + repeated TrustedDomain trusted_domain = 1; + + // Contains the total number of domains matching the query and the applied limit. + zitadel.filter.v2.PaginationResponse pagination = 2; +} diff --git a/proto/zitadel/instance/v2beta/instance_service.proto b/proto/zitadel/instance/v2beta/instance_service.proto index 184d3acf0..4793959f9 100644 --- a/proto/zitadel/instance/v2beta/instance_service.proto +++ b/proto/zitadel/instance/v2beta/instance_service.proto @@ -105,16 +105,21 @@ option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = { // Service to manage instances and their domains. // The service provides methods to create, update, delete and list instances and their domains. +// +// Deprecated: use instance service v2 instead. This service will be removed in the next major version of ZITADEL. service InstanceService { // Delete Instance // + // Deprecated: please move to the corresponding endpoint under instance service v2. This endpoint will be removed with the next major version of ZITADEL. + // // Deletes an instance with the given ID. // // Required permissions: // - `system.instance.delete` rpc DeleteInstance(DeleteInstanceRequest) returns (DeleteInstanceResponse) { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + deprecated: true; responses: { key: "200"; value: { @@ -136,6 +141,8 @@ service InstanceService { // Get Instance // + // Deprecated: please move to the corresponding endpoint under instance service v2. This endpoint will be removed with the next major version of ZITADEL. + // // Returns the instance in the current context. // // The instance_id in the input message will be used in the future. @@ -144,6 +151,7 @@ service InstanceService { // - `iam.read` rpc GetInstance(GetInstanceRequest) returns (GetInstanceResponse) { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + deprecated: true; responses: { key: "200"; value: { @@ -165,6 +173,8 @@ service InstanceService { // Update Instance // + // Deprecated: please move to the corresponding endpoint under instance service v2. This endpoint will be removed with the next major version of ZITADEL. + // // Updates instance in context with the given name. // // The instance_id in the input message will be used in the future. @@ -173,6 +183,7 @@ service InstanceService { // - `iam.write` rpc UpdateInstance(UpdateInstanceRequest) returns (UpdateInstanceResponse) { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + deprecated: true; responses: { key: "200"; value: { @@ -195,6 +206,8 @@ service InstanceService { // List Instances // + // Deprecated: please move to the corresponding endpoint under instance service v2. This endpoint will be removed with the next major version of ZITADEL. + // // Lists instances matching the given query. // The query can be used to filter either by instance ID or domain. // The request is paginated and returns 100 results by default. @@ -203,6 +216,7 @@ service InstanceService { // - `system.instance.read` rpc ListInstances(ListInstancesRequest) returns (ListInstancesResponse) { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + deprecated: true; responses: { key: "200"; value: { @@ -225,6 +239,8 @@ service InstanceService { // Add Custom Domain // + // Deprecated: please move to the corresponding endpoint under instance service v2. This endpoint will be removed with the next major version of ZITADEL. + // // Adds a custom domain to the instance in context. // // The instance_id in the input message will be used in the future @@ -233,6 +249,7 @@ service InstanceService { // - `system.domain.write` rpc AddCustomDomain(AddCustomDomainRequest) returns (AddCustomDomainResponse) { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + deprecated: true; responses: { key: "200"; value: { @@ -255,6 +272,8 @@ service InstanceService { // Remove Custom Domain // + // Deprecated: please move to the corresponding endpoint under instance service v2. This endpoint will be removed with the next major version of ZITADEL. + // // Removes a custom domain from the instance. // // The instance_id in the input message will be used in the future. @@ -263,6 +282,7 @@ service InstanceService { // - `system.domain.write` rpc RemoveCustomDomain(RemoveCustomDomainRequest) returns (RemoveCustomDomainResponse) { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + deprecated: true; responses: { key: "200"; value: { @@ -284,6 +304,8 @@ service InstanceService { // List Custom Domains // + // Deprecated: please move to the corresponding endpoint under instance service v2. This endpoint will be removed with the next major version of ZITADEL. + // // Lists custom domains of the instance. // // The instance_id in the input message will be used in the future. @@ -292,6 +314,7 @@ service InstanceService { // - `iam.read` rpc ListCustomDomains(ListCustomDomainsRequest) returns (ListCustomDomainsResponse) { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + deprecated: true; responses: { key: "200"; value: { @@ -314,6 +337,8 @@ service InstanceService { // Add Trusted Domain // + // Deprecated: please move to the corresponding endpoint under instance service v2. This endpoint will be removed with the next major version of ZITADEL. + // // Adds a trusted domain to the instance. // // The instance_id in the input message will be used in the future. @@ -322,6 +347,7 @@ service InstanceService { // - `iam.write` rpc AddTrustedDomain(AddTrustedDomainRequest) returns (AddTrustedDomainResponse) { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + deprecated: true; responses: { key: "200"; value: { @@ -344,6 +370,8 @@ service InstanceService { // Remove Trusted Domain // + // Deprecated: please move to the corresponding endpoint under instance service v2. This endpoint will be removed with the next major version of ZITADEL. + // // Removes a trusted domain from the instance. // // The instance_id in the input message will be used in the future. @@ -352,6 +380,7 @@ service InstanceService { // - `iam.write` rpc RemoveTrustedDomain(RemoveTrustedDomainRequest) returns (RemoveTrustedDomainResponse) { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + deprecated: true; responses: { key: "200"; value: { @@ -374,6 +403,8 @@ service InstanceService { // List Trusted Domains // + // Deprecated: please move to the corresponding endpoint under instance service v2. This endpoint will be removed with the next major version of ZITADEL. + // // Lists trusted domains of the instance. // // The instance_id in the input message will be used in the future. @@ -382,6 +413,7 @@ service InstanceService { // - `iam.read` rpc ListTrustedDomains(ListTrustedDomainsRequest) returns (ListTrustedDomainsResponse) { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + deprecated: true; responses: { key: "200"; value: { diff --git a/proto/zitadel/system.proto b/proto/zitadel/system.proto index 7b5810ebd..f97558c62 100644 --- a/proto/zitadel/system.proto +++ b/proto/zitadel/system.proto @@ -118,7 +118,7 @@ service SystemService { // Returns a list of ZITADEL instances // - // Deprecated: Use [ListInstances](apis/resources/instance_service_v2/zitadel-instance-v-2-beta-instance-service-list-instances.api.mdx) instead to list instances + // Deprecated: Use [ListInstances](apis/resources/instance_service_v2/zitadel-instance-v-2-instance-service-list-instances.api.mdx) instead to list instances rpc ListInstances(ListInstancesRequest) returns (ListInstancesResponse) { option (google.api.http) = { post: "/instances/_search" @@ -136,7 +136,7 @@ service SystemService { // Returns the detail of an instance // - // Deprecated: Use [GetInstance](apis/resources/instance_service_v2/zitadel-instance-v-2-beta-instance-service-get-instance.api.mdx) instead to get the details of the instance in context + // Deprecated: Use [GetInstance](apis/resources/instance_service_v2/zitadel-instance-v-2-instance-service-get-instance.api.mdx) instead to get the details of the instance in context rpc GetInstance(GetInstanceRequest) returns (GetInstanceResponse) { option (google.api.http) = { get: "/instances/{instance_id}"; @@ -171,7 +171,7 @@ service SystemService { // Updates name of an existing instance // - // Deprecated: Use [UpdateInstance](apis/resources/instance_service_v2/zitadel-instance-v-2-beta-instance-service-update-instance.api.mdx) instead to update the name of the instance in context + // Deprecated: Use [UpdateInstance](apis/resources/instance_service_v2/zitadel-instance-v-2-instance-service-update-instance.api.mdx) instead to update the name of the instance in context rpc UpdateInstance(UpdateInstanceRequest) returns (UpdateInstanceResponse) { option (google.api.http) = { put: "/instances/{instance_id}" @@ -203,7 +203,7 @@ service SystemService { // Removes an instance // This might take some time // - // Deprecated: Use [DeleteInstance](apis/resources/instance_service_v2/zitadel-instance-v-2-beta-instance-service-delete-instance.api.mdx) instead to delete an instance + // Deprecated: Use [DeleteInstance](apis/resources/instance_service_v2/zitadel-instance-v-2-instance-service-delete-instance.api.mdx) instead to delete an instance rpc RemoveInstance(RemoveInstanceRequest) returns (RemoveInstanceResponse) { option (google.api.http) = { delete: "/instances/{instance_id}" @@ -234,7 +234,7 @@ service SystemService { // Checks if a domain exists // - // Deprecated: Use [ListCustomDomains](apis/resources/instance_service_v2/zitadel-instance-v-2-beta-instance-service-list-custom-domains.api.mdx) instead to check existence of an instance + // Deprecated: Use [ListCustomDomains](apis/resources/instance_service_v2/zitadel-instance-v-2-instance-service-list-custom-domains.api.mdx) instead to check existence of an instance rpc ExistsDomain(ExistsDomainRequest) returns (ExistsDomainResponse) { option (google.api.http) = { post: "/domains/{domain}/_exists"; @@ -252,7 +252,7 @@ service SystemService { // List Domains // - // Deprecated: use [instance service v2 ListCustomDomains](apis/resources/instance_service_v2/zitadel-instance-v-2-beta-instance-service-list-custom-domains.api.mdx) instead. + // Deprecated: use [instance service v2 ListCustomDomains](apis/resources/instance_service_v2/zitadel-instance-v-2-instance-service-list-custom-domains.api.mdx) instead. // // Returns the custom domains of an instance. rpc ListDomains(ListDomainsRequest) returns (ListDomainsResponse) { @@ -272,7 +272,7 @@ service SystemService { // Adds a domain to an instance // - // Deprecated: Use [AddCustomDomain](apis/resources/instance_service_v2/zitadel-instance-v-2-beta-instance-service-add-custom-domain.api.mdx) instead to add a custom domain to the instance in context + // Deprecated: Use [AddCustomDomain](apis/resources/instance_service_v2/zitadel-instance-v-2-instance-service-add-custom-domain.api.mdx) instead to add a custom domain to the instance in context rpc AddDomain(AddDomainRequest) returns (AddDomainResponse) { option (google.api.http) = { post: "/instances/{instance_id}/domains"; @@ -290,7 +290,7 @@ service SystemService { // Removes the domain of an instance // - // Deprecated: Use [RemoveDomain](apis/resources/instance_service_v2/zitadel-instance-v-2-beta-instance-service-remove-custom-domain.api.mdx) instead to remove a custom domain from the instance in context + // Deprecated: Use [RemoveDomain](apis/resources/instance_service_v2/zitadel-instance-v-2-instance-service-remove-custom-domain.api.mdx) instead to remove a custom domain from the instance in context rpc RemoveDomain(RemoveDomainRequest) returns (RemoveDomainResponse) { option (google.api.http) = { delete: "/instances/{instance_id}/domains/{domain}";