diff --git a/cmd/start/start.go b/cmd/start/start.go index 84b38fe83e0..235f99ec6c1 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 a5280334ebc..e8786d1616e 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 78604dfb0ed..452fb6730b3 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 d92532a30f4..7641c56c6bd 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 00000000000..d60bfbe64fd --- /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 00000000000..c8086b3b8d6 --- /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 00000000000..99449105318 --- /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 00000000000..cead58ed84b --- /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 00000000000..09cdeb49bb9 --- /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 00000000000..57477343edc --- /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 00000000000..87bb37bdd86 --- /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 00000000000..d764bdc926e --- /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 00000000000..afd88c027c3 --- /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 50fe774db47..c8f517857e1 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 d0945770a78..3841fa67d12 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 f63c5f34510..933d9ccfdf7 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 2d8bda192f8..df4dd396b76 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 ffc7a0f2125..5b42f822a74 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 f9976810328..f500f9e5f0c 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 00000000000..6f1d44f0d40 --- /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 00000000000..c9d83bf9980 --- /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 184d3acf0ac..4793959f98b 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 7b5810ebdd5..f97558c6209 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}";