From b339a3e69612550cc9ad0ad47372469d69ee9d02 Mon Sep 17 00:00:00 2001 From: "Marco A." Date: Wed, 17 Dec 2025 12:01:09 +0100 Subject: [PATCH] feat: instance domain APIs with relational tables (#11051) # Which Problems Are Solved This PR implements the endpoints listed in https://github.com/zitadel/zitadel/issues/10444 . # How the Problems Are Solved The implementation follows the same pattern as the Instance one. The `Add` and `Remove` are implementing the `Commander` interface, while `List` Domains endpoints are implementing the `Querier` interace. The PR adds also some forgotten calls to the relational logic for the Instance API. The commits are identifiable by the label `instance:` prepended to them. This is the complete list: bafce01b0 , 6fe7257ab , e27bc9c34 , 8133ede73 , fde667953 , a3e1135ed , ee1a6f9c1 # Additional Context - Closes #10444 --------- Co-authored-by: Elio Bischof Co-authored-by: Lebogang Phoshoko Co-authored-by: Wim Van Laer Co-authored-by: Max Peintner Co-authored-by: Gayathri Vijayan <66356931+grvijayan@users.noreply.github.com> --- backend/v3/api/instance/v2/convert/domain.go | 191 +++++ .../v3/api/instance/v2/convert/domain_test.go | 426 +++++++++++ .../v3/api/instance/v2/convert/instance.go | 116 ++- .../api/instance/v2/convert/instance_test.go | 357 ++++++++- backend/v3/api/instance/v2/domain.go | 287 ++++++++ backend/v3/api/instance/v2/instance.go | 139 +++- backend/v3/api/org/v2/org.go | 94 +-- backend/v3/domain/instance_delete.go | 23 +- backend/v3/domain/instance_delete_test.go | 76 +- backend/v3/domain/instance_domain.go | 2 + backend/v3/domain/instance_domain_add.go | 123 ++++ backend/v3/domain/instance_domain_add_test.go | 316 ++++++++ backend/v3/domain/instance_domain_list.go | 134 ++++ .../v3/domain/instance_domain_list_test.go | 361 ++++++++++ backend/v3/domain/instance_domain_remove.go | 118 +++ .../v3/domain/instance_domain_remove_test.go | 434 +++++++++++ backend/v3/domain/instance_get.go | 10 +- backend/v3/domain/instance_get_test.go | 34 +- backend/v3/domain/instance_list.go | 36 +- backend/v3/domain/instance_list_test.go | 34 +- .../v3/domain/instance_trusted_domain_list.go | 133 ++++ .../instance_trusted_domain_list_test.go | 336 +++++++++ backend/v3/domain/instance_update.go | 35 +- backend/v3/domain/instance_update_test.go | 93 ++- .../v3/domain/mock/instance_domain.mock.go | 38 + .../v3/domain/oidc_configuration_update.go | 66 ++ backend/v3/domain/permission.go | 2 + .../database/repository/instance_domain.go | 5 + internal/api/grpc/admin/instance.go | 2 +- internal/api/grpc/instance/v2/domain.go | 36 +- internal/api/grpc/instance/v2/instance.go | 16 +- .../v2/integration_test/domain_test.go | 592 ++++++++------- .../v2/integration_test/instance_test.go | 476 +++++++----- .../v2/integration_test/query_test.go | 679 +++++++++++------- internal/api/grpc/instance/v2/query.go | 19 +- internal/api/grpc/instance/v2beta/domain.go | 36 +- internal/api/grpc/instance/v2beta/instance.go | 13 +- .../v2beta/integration_test/domain_test.go | 584 ++++++++------- .../v2beta/integration_test/instance_test.go | 42 +- .../v2beta/integration_test/query_test.go | 346 +++++---- internal/api/grpc/instance/v2beta/query.go | 12 +- internal/api/grpc/org/v2beta/org.go | 8 +- internal/api/grpc/system/instance.go | 4 +- internal/command/instance.go | 71 +- internal/command/instance_domain.go | 56 +- internal/command/instance_domain_test.go | 29 +- internal/command/instance_test.go | 60 +- internal/command/instance_trusted_domain.go | 8 +- .../command/instance_trusted_domain_test.go | 28 +- 49 files changed, 5670 insertions(+), 1466 deletions(-) create mode 100644 backend/v3/api/instance/v2/convert/domain.go create mode 100644 backend/v3/api/instance/v2/convert/domain_test.go create mode 100644 backend/v3/api/instance/v2/domain.go create mode 100644 backend/v3/domain/instance_domain_add.go create mode 100644 backend/v3/domain/instance_domain_add_test.go create mode 100644 backend/v3/domain/instance_domain_list.go create mode 100644 backend/v3/domain/instance_domain_list_test.go create mode 100644 backend/v3/domain/instance_domain_remove.go create mode 100644 backend/v3/domain/instance_domain_remove_test.go create mode 100644 backend/v3/domain/instance_trusted_domain_list.go create mode 100644 backend/v3/domain/instance_trusted_domain_list_test.go create mode 100644 backend/v3/domain/oidc_configuration_update.go diff --git a/backend/v3/api/instance/v2/convert/domain.go b/backend/v3/api/instance/v2/convert/domain.go new file mode 100644 index 000000000..fb9b78ff0 --- /dev/null +++ b/backend/v3/api/instance/v2/convert/domain.go @@ -0,0 +1,191 @@ +package convert + +import ( + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/backend/v3/domain" + filter_v2 "github.com/zitadel/zitadel/pkg/grpc/filter/v2" + instance_v2 "github.com/zitadel/zitadel/pkg/grpc/instance/v2" + instance_v2beta "github.com/zitadel/zitadel/pkg/grpc/instance/v2beta" +) + +/* + * Domain Model to GRPC v2Beta + */ + +func DomainInstanceDomainListModelToGRPCBetaResponse(dms []*domain.InstanceDomain) []*instance_v2beta.Domain { + toReturn := make([]*instance_v2beta.Domain, len(dms)) + for i, domain := range dms { + isGenerated := domain.IsGenerated != nil && *domain.IsGenerated + isPrimary := domain.IsPrimary != nil && *domain.IsPrimary + toReturn[i] = &instance_v2beta.Domain{ + InstanceId: domain.InstanceID, + CreationDate: timestamppb.New(domain.CreatedAt), + Domain: domain.Domain, + Primary: isPrimary, + Generated: isGenerated, + } + } + + return toReturn +} + +func TrustedDomainInstanceDomainListModelToGRPCBetaResponse(dms []*domain.InstanceDomain) []*instance_v2beta.TrustedDomain { + toReturn := make([]*instance_v2beta.TrustedDomain, len(dms)) + for i, domain := range dms { + toReturn[i] = &instance_v2beta.TrustedDomain{ + InstanceId: domain.InstanceID, + CreationDate: timestamppb.New(domain.CreatedAt), + Domain: domain.Domain, + } + } + + return toReturn +} + +/* + * Domain Model to GRPC v2 + */ + +func DomainInstanceDomainListModelToGRPCResponse(dms []*domain.InstanceDomain) []*instance_v2.CustomDomain { + toReturn := make([]*instance_v2.CustomDomain, len(dms)) + for i, domain := range dms { + isGenerated := domain.IsGenerated != nil && *domain.IsGenerated + isPrimary := domain.IsPrimary != nil && *domain.IsPrimary + toReturn[i] = &instance_v2.CustomDomain{ + InstanceId: domain.InstanceID, + CreationDate: timestamppb.New(domain.CreatedAt), + Domain: domain.Domain, + Primary: isPrimary, + Generated: isGenerated, + } + } + + return toReturn +} + +func TrustedDomainInstanceDomainListModelToGRPCResponse(dms []*domain.InstanceDomain) []*instance_v2.TrustedDomain { + toReturn := make([]*instance_v2.TrustedDomain, len(dms)) + for i, domain := range dms { + toReturn[i] = &instance_v2.TrustedDomain{ + InstanceId: domain.InstanceID, + CreationDate: timestamppb.New(domain.CreatedAt), + Domain: domain.Domain, + } + } + + return toReturn +} + +/* + * GRPC v2Beta Requests to GRPC v2 + */ + +func ListCustomDomainsBetaRequestToV2Request(in *instance_v2beta.ListCustomDomainsRequest) *instance_v2.ListCustomDomainsRequest { + return &instance_v2.ListCustomDomainsRequest{ + InstanceId: in.GetInstanceId(), + Pagination: &filter_v2.PaginationRequest{ + Offset: in.GetPagination().GetOffset(), + Limit: in.GetPagination().GetLimit(), + Asc: in.GetPagination().GetAsc(), + }, + SortingColumn: listCustomDomainsBetaSortingColToV2Request(in.GetSortingColumn()), + Filters: listCustomDomainsQueriesToV2Request(in.GetQueries()), + } +} + +func listCustomDomainsBetaSortingColToV2Request(domainFieldName instance_v2beta.DomainFieldName) instance_v2.DomainFieldName { + switch domainFieldName { + case instance_v2beta.DomainFieldName_DOMAIN_FIELD_NAME_CREATION_DATE: + return instance_v2.DomainFieldName_DOMAIN_FIELD_NAME_CREATION_DATE + case instance_v2beta.DomainFieldName_DOMAIN_FIELD_NAME_DOMAIN: + return instance_v2.DomainFieldName_DOMAIN_FIELD_NAME_DOMAIN + case instance_v2beta.DomainFieldName_DOMAIN_FIELD_NAME_GENERATED: + return instance_v2.DomainFieldName_DOMAIN_FIELD_NAME_GENERATED + case instance_v2beta.DomainFieldName_DOMAIN_FIELD_NAME_PRIMARY: + return instance_v2.DomainFieldName_DOMAIN_FIELD_NAME_PRIMARY + case instance_v2beta.DomainFieldName_DOMAIN_FIELD_NAME_UNSPECIFIED: + fallthrough + default: + return instance_v2.DomainFieldName_DOMAIN_FIELD_NAME_UNSPECIFIED + } +} + +func listCustomDomainsQueriesToV2Request(domainSearchQuery []*instance_v2beta.DomainSearchQuery) []*instance_v2.CustomDomainFilter { + toReturn := make([]*instance_v2.CustomDomainFilter, len(domainSearchQuery)) + + for i, query := range domainSearchQuery { + switch assertedType := query.GetQuery().(type) { + case *instance_v2beta.DomainSearchQuery_DomainQuery: + filter := &instance_v2.CustomDomainFilter_DomainFilter{ + DomainFilter: &instance_v2.DomainFilter{ + Domain: assertedType.DomainQuery.GetDomain(), + Method: assertedType.DomainQuery.GetMethod(), + }, + } + toReturn[i] = &instance_v2.CustomDomainFilter{ + Filter: filter, + } + case *instance_v2beta.DomainSearchQuery_GeneratedQuery: + filter := &instance_v2.CustomDomainFilter_GeneratedFilter{ + GeneratedFilter: assertedType.GeneratedQuery.GetGenerated(), + } + toReturn[i] = &instance_v2.CustomDomainFilter{ + Filter: filter, + } + case *instance_v2beta.DomainSearchQuery_PrimaryQuery: + filter := &instance_v2.CustomDomainFilter_PrimaryFilter{ + PrimaryFilter: assertedType.PrimaryQuery.GetPrimary(), + } + toReturn[i] = &instance_v2.CustomDomainFilter{ + Filter: filter, + } + } + } + return toReturn +} + +func ListTrustedDomainsBetaRequestToV2Request(in *instance_v2beta.ListTrustedDomainsRequest) *instance_v2.ListTrustedDomainsRequest { + return &instance_v2.ListTrustedDomainsRequest{ + InstanceId: in.GetInstanceId(), + Pagination: &filter_v2.PaginationRequest{ + Offset: in.GetPagination().GetOffset(), + Limit: in.GetPagination().GetLimit(), + Asc: in.GetPagination().GetAsc(), + }, + SortingColumn: listTrustedDomainsBetaSortingColToV2Request(in.GetSortingColumn()), + Filters: listTrustedDomainsQueriesToV2Request(in.GetQueries()), + } +} + +func listTrustedDomainsBetaSortingColToV2Request(domainFieldName instance_v2beta.TrustedDomainFieldName) instance_v2.TrustedDomainFieldName { + switch domainFieldName { + case instance_v2beta.TrustedDomainFieldName_TRUSTED_DOMAIN_FIELD_NAME_CREATION_DATE: + return instance_v2.TrustedDomainFieldName_TRUSTED_DOMAIN_FIELD_NAME_CREATION_DATE + case instance_v2beta.TrustedDomainFieldName_TRUSTED_DOMAIN_FIELD_NAME_DOMAIN: + return instance_v2.TrustedDomainFieldName_TRUSTED_DOMAIN_FIELD_NAME_DOMAIN + case instance_v2beta.TrustedDomainFieldName_TRUSTED_DOMAIN_FIELD_NAME_UNSPECIFIED: + fallthrough + default: + return instance_v2.TrustedDomainFieldName_TRUSTED_DOMAIN_FIELD_NAME_UNSPECIFIED + } +} + +func listTrustedDomainsQueriesToV2Request(domainSearchQuery []*instance_v2beta.TrustedDomainSearchQuery) []*instance_v2.TrustedDomainFilter { + toReturn := make([]*instance_v2.TrustedDomainFilter, len(domainSearchQuery)) + + for i, query := range domainSearchQuery { + if assertedType, ok := query.GetQuery().(*instance_v2beta.TrustedDomainSearchQuery_DomainQuery); ok { + filter := &instance_v2.TrustedDomainFilter_DomainFilter{ + DomainFilter: &instance_v2.DomainFilter{ + Domain: assertedType.DomainQuery.GetDomain(), + Method: assertedType.DomainQuery.GetMethod(), + }, + } + toReturn[i] = &instance_v2.TrustedDomainFilter{ + Filter: filter, + } + } + } + return toReturn +} diff --git a/backend/v3/api/instance/v2/convert/domain_test.go b/backend/v3/api/instance/v2/convert/domain_test.go new file mode 100644 index 000000000..aa9afcdea --- /dev/null +++ b/backend/v3/api/instance/v2/convert/domain_test.go @@ -0,0 +1,426 @@ +package convert + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/backend/v3/domain" + filter_v2 "github.com/zitadel/zitadel/pkg/grpc/filter/v2" + filter_v2beta "github.com/zitadel/zitadel/pkg/grpc/filter/v2beta" + instance_v2 "github.com/zitadel/zitadel/pkg/grpc/instance/v2" + instance_v2beta "github.com/zitadel/zitadel/pkg/grpc/instance/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/object/v2" +) + +func TestListCustomDomainsBetaRequestToV2Request(t *testing.T) { + t.Parallel() + + tt := []struct { + testName string + inputRequest *instance_v2beta.ListCustomDomainsRequest + expectedRequest *instance_v2.ListCustomDomainsRequest + }{ + { + testName: "with all fields", + inputRequest: &instance_v2beta.ListCustomDomainsRequest{ + InstanceId: "instance1", + Pagination: &filter_v2beta.PaginationRequest{ + Offset: 0, + Limit: 10, + Asc: true, + }, + SortingColumn: instance_v2beta.DomainFieldName_DOMAIN_FIELD_NAME_DOMAIN, + Queries: []*instance_v2beta.DomainSearchQuery{ + { + Query: &instance_v2beta.DomainSearchQuery_DomainQuery{ + DomainQuery: &instance_v2beta.DomainQuery{ + Domain: "test.com", + Method: object.TextQueryMethod_TEXT_QUERY_METHOD_EQUALS, + }, + }, + }, + { + Query: &instance_v2beta.DomainSearchQuery_GeneratedQuery{ + GeneratedQuery: &instance_v2beta.DomainGeneratedQuery{ + Generated: true, + }, + }, + }, + { + Query: &instance_v2beta.DomainSearchQuery_PrimaryQuery{ + PrimaryQuery: &instance_v2beta.DomainPrimaryQuery{ + Primary: true, + }, + }, + }, + }, + }, + expectedRequest: &instance_v2.ListCustomDomainsRequest{ + InstanceId: "instance1", + Pagination: &filter_v2.PaginationRequest{ + Offset: 0, + Limit: 10, + Asc: true, + }, + SortingColumn: instance_v2.DomainFieldName_DOMAIN_FIELD_NAME_DOMAIN, + Filters: []*instance_v2.CustomDomainFilter{ + { + Filter: &instance_v2.CustomDomainFilter_DomainFilter{ + DomainFilter: &instance_v2.DomainFilter{ + Domain: "test.com", + Method: object.TextQueryMethod_TEXT_QUERY_METHOD_EQUALS, + }, + }, + }, + { + Filter: &instance_v2.CustomDomainFilter_GeneratedFilter{ + GeneratedFilter: true, + }, + }, + { + Filter: &instance_v2.CustomDomainFilter_PrimaryFilter{ + PrimaryFilter: true, + }, + }, + }, + }, + }, + { + testName: "empty request", + inputRequest: &instance_v2beta.ListCustomDomainsRequest{}, + expectedRequest: &instance_v2.ListCustomDomainsRequest{ + Pagination: &filter_v2.PaginationRequest{}, + Filters: []*instance_v2.CustomDomainFilter{}, + }, + }, + { + testName: "with sorting only", + inputRequest: &instance_v2beta.ListCustomDomainsRequest{ + SortingColumn: instance_v2beta.DomainFieldName_DOMAIN_FIELD_NAME_CREATION_DATE, + }, + expectedRequest: &instance_v2.ListCustomDomainsRequest{ + Pagination: &filter_v2.PaginationRequest{}, + SortingColumn: instance_v2.DomainFieldName_DOMAIN_FIELD_NAME_CREATION_DATE, + Filters: []*instance_v2.CustomDomainFilter{}, + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + got := ListCustomDomainsBetaRequestToV2Request(tc.inputRequest) + assert.Equal(t, tc.expectedRequest, got) + }) + } +} + +func Test_listCustomDomainsBetaSortingColToV2Request(t *testing.T) { + t.Parallel() + + tt := []struct { + testName string + inputFieldName instance_v2beta.DomainFieldName + expecteFieldName instance_v2.DomainFieldName + }{ + { + testName: "creation date", + inputFieldName: instance_v2beta.DomainFieldName_DOMAIN_FIELD_NAME_CREATION_DATE, + expecteFieldName: instance_v2.DomainFieldName_DOMAIN_FIELD_NAME_CREATION_DATE, + }, + { + testName: "domain", + inputFieldName: instance_v2beta.DomainFieldName_DOMAIN_FIELD_NAME_DOMAIN, + expecteFieldName: instance_v2.DomainFieldName_DOMAIN_FIELD_NAME_DOMAIN, + }, + { + testName: "generated", + inputFieldName: instance_v2beta.DomainFieldName_DOMAIN_FIELD_NAME_GENERATED, + expecteFieldName: instance_v2.DomainFieldName_DOMAIN_FIELD_NAME_GENERATED, + }, + { + testName: "primary", + inputFieldName: instance_v2beta.DomainFieldName_DOMAIN_FIELD_NAME_PRIMARY, + expecteFieldName: instance_v2.DomainFieldName_DOMAIN_FIELD_NAME_PRIMARY, + }, + { + testName: "unspecified", + inputFieldName: instance_v2beta.DomainFieldName_DOMAIN_FIELD_NAME_UNSPECIFIED, + expecteFieldName: instance_v2.DomainFieldName_DOMAIN_FIELD_NAME_UNSPECIFIED, + }, + { + testName: "default", + inputFieldName: instance_v2beta.DomainFieldName(99), + expecteFieldName: instance_v2.DomainFieldName_DOMAIN_FIELD_NAME_UNSPECIFIED, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + got := listCustomDomainsBetaSortingColToV2Request(tc.inputFieldName) + assert.Equal(t, tc.expecteFieldName, got) + }) + } +} + +func TestListTrustedDomainsBetaRequestToV2Request(t *testing.T) { + t.Parallel() + + tt := []struct { + testName string + inputRequest *instance_v2beta.ListTrustedDomainsRequest + expectedRequest *instance_v2.ListTrustedDomainsRequest + }{ + { + testName: "with all fields", + inputRequest: &instance_v2beta.ListTrustedDomainsRequest{ + InstanceId: "instance1", + Pagination: &filter_v2beta.PaginationRequest{ + Offset: 0, + Limit: 10, + Asc: true, + }, + SortingColumn: instance_v2beta.TrustedDomainFieldName_TRUSTED_DOMAIN_FIELD_NAME_DOMAIN, + Queries: []*instance_v2beta.TrustedDomainSearchQuery{ + { + Query: &instance_v2beta.TrustedDomainSearchQuery_DomainQuery{ + DomainQuery: &instance_v2beta.DomainQuery{ + Domain: "test.com", + Method: object.TextQueryMethod_TEXT_QUERY_METHOD_EQUALS, + }, + }, + }, + }, + }, + expectedRequest: &instance_v2.ListTrustedDomainsRequest{ + InstanceId: "instance1", + Pagination: &filter_v2.PaginationRequest{ + Offset: 0, + Limit: 10, + Asc: true, + }, + SortingColumn: instance_v2.TrustedDomainFieldName_TRUSTED_DOMAIN_FIELD_NAME_DOMAIN, + Filters: []*instance_v2.TrustedDomainFilter{ + { + Filter: &instance_v2.TrustedDomainFilter_DomainFilter{ + DomainFilter: &instance_v2.DomainFilter{ + Domain: "test.com", + Method: object.TextQueryMethod_TEXT_QUERY_METHOD_EQUALS, + }, + }, + }, + }, + }, + }, + { + testName: "empty request", + inputRequest: &instance_v2beta.ListTrustedDomainsRequest{}, + expectedRequest: &instance_v2.ListTrustedDomainsRequest{ + Pagination: &filter_v2.PaginationRequest{}, + Filters: []*instance_v2.TrustedDomainFilter{}, + }, + }, + { + testName: "with sorting only", + inputRequest: &instance_v2beta.ListTrustedDomainsRequest{ + SortingColumn: instance_v2beta.TrustedDomainFieldName_TRUSTED_DOMAIN_FIELD_NAME_CREATION_DATE, + }, + expectedRequest: &instance_v2.ListTrustedDomainsRequest{ + Pagination: &filter_v2.PaginationRequest{}, + SortingColumn: instance_v2.TrustedDomainFieldName_TRUSTED_DOMAIN_FIELD_NAME_CREATION_DATE, + Filters: []*instance_v2.TrustedDomainFilter{}, + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + got := ListTrustedDomainsBetaRequestToV2Request(tc.inputRequest) + assert.Equal(t, tc.expectedRequest, got) + }) + } +} + +func Test_listTrustedDomainsBetaSortingColToV2Request(t *testing.T) { + t.Parallel() + + tt := []struct { + testName string + inputFieldName instance_v2beta.TrustedDomainFieldName + expecteFieldName instance_v2.TrustedDomainFieldName + }{ + { + testName: "creation date", + inputFieldName: instance_v2beta.TrustedDomainFieldName_TRUSTED_DOMAIN_FIELD_NAME_CREATION_DATE, + expecteFieldName: instance_v2.TrustedDomainFieldName_TRUSTED_DOMAIN_FIELD_NAME_CREATION_DATE, + }, + { + testName: "domain", + inputFieldName: instance_v2beta.TrustedDomainFieldName_TRUSTED_DOMAIN_FIELD_NAME_DOMAIN, + expecteFieldName: instance_v2.TrustedDomainFieldName_TRUSTED_DOMAIN_FIELD_NAME_DOMAIN, + }, + { + testName: "unspecified", + inputFieldName: instance_v2beta.TrustedDomainFieldName_TRUSTED_DOMAIN_FIELD_NAME_UNSPECIFIED, + expecteFieldName: instance_v2.TrustedDomainFieldName_TRUSTED_DOMAIN_FIELD_NAME_UNSPECIFIED, + }, + { + testName: "default", + inputFieldName: instance_v2beta.TrustedDomainFieldName(99), + expecteFieldName: instance_v2.TrustedDomainFieldName_TRUSTED_DOMAIN_FIELD_NAME_UNSPECIFIED, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + got := listTrustedDomainsBetaSortingColToV2Request(tc.inputFieldName) + assert.Equal(t, tc.expecteFieldName, got) + }) + } +} + +func TestTrustedDomainInstanceDomainListModelToGRPCBetaResponse(t *testing.T) { + t.Parallel() + + testTime := time.Now() + + tt := []struct { + name string + input []*domain.InstanceDomain + expected []*instance_v2beta.TrustedDomain + }{ + { + name: "single domain", + input: []*domain.InstanceDomain{ + { + InstanceID: "instance1", + CreatedAt: testTime, + Domain: "test.com", + }, + }, + expected: []*instance_v2beta.TrustedDomain{ + { + InstanceId: "instance1", + CreationDate: timestamppb.New(testTime), + Domain: "test.com", + }, + }, + }, + { + name: "multiple domains", + input: []*domain.InstanceDomain{ + { + InstanceID: "instance1", + CreatedAt: testTime, + Domain: "test1.com", + }, + { + InstanceID: "instance2", + CreatedAt: testTime, + Domain: "test2.com", + }, + }, + expected: []*instance_v2beta.TrustedDomain{ + { + InstanceId: "instance1", + CreationDate: timestamppb.New(testTime), + Domain: "test1.com", + }, + { + InstanceId: "instance2", + CreationDate: timestamppb.New(testTime), + Domain: "test2.com", + }, + }, + }, + { + name: "empty list", + input: []*domain.InstanceDomain{}, + expected: []*instance_v2beta.TrustedDomain{}, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got := TrustedDomainInstanceDomainListModelToGRPCBetaResponse(tc.input) + assert.Equal(t, tc.expected, got) + }) + } +} + +func TestTrustedDomainInstanceDomainListModelToGRPCResponse(t *testing.T) { + t.Parallel() + + testTime := time.Now() + + tt := []struct { + name string + input []*domain.InstanceDomain + expected []*instance_v2.TrustedDomain + }{ + { + name: "single domain", + input: []*domain.InstanceDomain{ + { + InstanceID: "instance1", + CreatedAt: testTime, + Domain: "test.com", + }, + }, + expected: []*instance_v2.TrustedDomain{ + { + InstanceId: "instance1", + CreationDate: timestamppb.New(testTime), + Domain: "test.com", + }, + }, + }, + { + name: "multiple domains", + input: []*domain.InstanceDomain{ + { + InstanceID: "instance1", + CreatedAt: testTime, + Domain: "test1.com", + }, + { + InstanceID: "instance2", + CreatedAt: testTime, + Domain: "test2.com", + }, + }, + expected: []*instance_v2.TrustedDomain{ + { + InstanceId: "instance1", + CreationDate: timestamppb.New(testTime), + Domain: "test1.com", + }, + { + InstanceId: "instance2", + CreationDate: timestamppb.New(testTime), + Domain: "test2.com", + }, + }, + }, + { + name: "empty list", + input: []*domain.InstanceDomain{}, + expected: []*instance_v2.TrustedDomain{}, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got := TrustedDomainInstanceDomainListModelToGRPCResponse(tc.input) + assert.Equal(t, tc.expected, got) + }) + } +} diff --git a/backend/v3/api/instance/v2/convert/instance.go b/backend/v3/api/instance/v2/convert/instance.go index 81047c190..11f2ab41c 100644 --- a/backend/v3/api/instance/v2/convert/instance.go +++ b/backend/v3/api/instance/v2/convert/instance.go @@ -5,15 +5,42 @@ import ( "github.com/zitadel/zitadel/backend/v3/domain" "github.com/zitadel/zitadel/cmd/build" - instance "github.com/zitadel/zitadel/pkg/grpc/instance/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/filter/v2" + instance_v2 "github.com/zitadel/zitadel/pkg/grpc/instance/v2" + instance_v2beta "github.com/zitadel/zitadel/pkg/grpc/instance/v2beta" ) +/* + * Domain Model to GRPC v2Beta + */ + +func DomainInstanceListModelToGRPCBetaResponse(instances []*domain.Instance) []*instance_v2beta.Instance { + toReturn := make([]*instance_v2beta.Instance, len(instances)) + + for i, inst := range instances { + toReturn[i] = DomainInstanceModelToGRPCBetaResponse(inst) + } + + return toReturn +} + +func DomainInstanceModelToGRPCBetaResponse(inst *domain.Instance) *instance_v2beta.Instance { + return &instance_v2beta.Instance{ + Id: inst.ID, + ChangeDate: timestamppb.New(inst.UpdatedAt), + CreationDate: timestamppb.New(inst.CreatedAt), + Name: inst.Name, + Version: build.Version(), + Domains: DomainInstanceDomainListModelToGRPCBetaResponse(inst.Domains), + } +} + /* * Domain Model to GRPC v2 */ -func DomainInstanceListModelToGRPCResponse(instances []*domain.Instance) []*instance.Instance { - toReturn := make([]*instance.Instance, len(instances)) +func DomainInstanceListModelToGRPCResponse(instances []*domain.Instance) []*instance_v2.Instance { + toReturn := make([]*instance_v2.Instance, len(instances)) for i, inst := range instances { toReturn[i] = DomainInstanceModelToGRPCResponse(inst) @@ -22,31 +49,72 @@ func DomainInstanceListModelToGRPCResponse(instances []*domain.Instance) []*inst return toReturn } -func DomainInstanceModelToGRPCResponse(inst *domain.Instance) *instance.Instance { - return &instance.Instance{ - Id: inst.ID, - ChangeDate: timestamppb.New(inst.UpdatedAt), - CreationDate: timestamppb.New(inst.CreatedAt), - State: instance.State_STATE_RUNNING, // TODO(IAM-Marco): Not sure what to put here - Name: inst.Name, - Version: build.Version(), - Domains: domainInstanceDomainListModelToGRPCResponse(inst.Domains), +func DomainInstanceModelToGRPCResponse(inst *domain.Instance) *instance_v2.Instance { + return &instance_v2.Instance{ + Id: inst.ID, + ChangeDate: timestamppb.New(inst.UpdatedAt), + CreationDate: timestamppb.New(inst.CreatedAt), + State: instance_v2.State_STATE_RUNNING, // TODO(IAM-Marco): Not sure what to put here + Name: inst.Name, + Version: build.Version(), + CustomDomains: DomainInstanceDomainListModelToGRPCResponse(inst.Domains), } } -func domainInstanceDomainListModelToGRPCResponse(dms []*domain.InstanceDomain) []*instance.Domain { - toReturn := make([]*instance.Domain, len(dms)) - for i, domain := range dms { - isGenerated := domain.IsGenerated != nil && *domain.IsGenerated - isPrimary := domain.IsPrimary != nil && *domain.IsPrimary - toReturn[i] = &instance.Domain{ - InstanceId: domain.InstanceID, - CreationDate: timestamppb.New(domain.CreatedAt), - Domain: domain.Domain, - Primary: isPrimary, - Generated: isGenerated, +/* + * GRPC v2Beta Requests to GRPC v2 + */ + +func ListInstancesBetaRequestToV2Request(in *instance_v2beta.ListInstancesRequest) *instance_v2.ListInstancesRequest { + return &instance_v2.ListInstancesRequest{ + Pagination: &filter.PaginationRequest{ + Offset: in.GetPagination().GetOffset(), + Limit: in.GetPagination().GetLimit(), + Asc: in.GetPagination().GetAsc(), + }, + SortingColumn: listInstancesBetaSortingColToV2Request(in.SortingColumn), + Filters: listInstancesQueriesToV2Request(in.GetQueries()), + } +} + +func listInstancesQueriesToV2Request(queries []*instance_v2beta.Query) []*instance_v2.Filter { + toReturn := make([]*instance_v2.Filter, len(queries)) + for i, query := range queries { + switch assertedQuery := query.GetQuery().(type) { + case *instance_v2beta.Query_DomainQuery: + filter := &instance_v2.Filter_CustomDomainsFilter{ + CustomDomainsFilter: &instance_v2.CustomDomainsFilter{ + Domains: assertedQuery.DomainQuery.GetDomains(), + }, + } + toReturn[i] = &instance_v2.Filter{Filter: filter} + case *instance_v2beta.Query_IdQuery: + filter := &instance_v2.Filter_InIdsFilter{ + InIdsFilter: &filter.InIDsFilter{ + Ids: assertedQuery.IdQuery.GetIds(), + }, + } + toReturn[i] = &instance_v2.Filter{Filter: filter} } } - return toReturn } + +func listInstancesBetaSortingColToV2Request(fieldName *instance_v2beta.FieldName) instance_v2.FieldName { + if fieldName == nil { + return instance_v2.FieldName_FIELD_NAME_UNSPECIFIED + } + + switch *fieldName { + case instance_v2beta.FieldName_FIELD_NAME_CREATION_DATE: + return instance_v2.FieldName_FIELD_NAME_CREATION_DATE + case instance_v2beta.FieldName_FIELD_NAME_ID: + return instance_v2.FieldName_FIELD_NAME_ID + case instance_v2beta.FieldName_FIELD_NAME_NAME: + return instance_v2.FieldName_FIELD_NAME_NAME + case instance_v2beta.FieldName_FIELD_NAME_UNSPECIFIED: + fallthrough + default: + return instance_v2.FieldName_FIELD_NAME_UNSPECIFIED + } +} diff --git a/backend/v3/api/instance/v2/convert/instance_test.go b/backend/v3/api/instance/v2/convert/instance_test.go index 134ba0e6d..ef11b0386 100644 --- a/backend/v3/api/instance/v2/convert/instance_test.go +++ b/backend/v3/api/instance/v2/convert/instance_test.go @@ -9,9 +9,99 @@ import ( "google.golang.org/protobuf/types/known/timestamppb" "github.com/zitadel/zitadel/backend/v3/domain" - instance "github.com/zitadel/zitadel/pkg/grpc/instance/v2beta" + filter_v2 "github.com/zitadel/zitadel/pkg/grpc/filter/v2" + filter_v2beta "github.com/zitadel/zitadel/pkg/grpc/filter/v2beta" + instance_v2 "github.com/zitadel/zitadel/pkg/grpc/instance/v2" + instance_v2beta "github.com/zitadel/zitadel/pkg/grpc/instance/v2beta" ) +func TestDomainInstanceModelToGRPCBetaResponse(t *testing.T) { + // Given + t.Parallel() + + now := time.Now().UTC() + yesterday := now.AddDate(0, 0, -1) + + inputInstance := &domain.Instance{ + ID: "instance-1", + Name: "Instance One", + CreatedAt: yesterday, + UpdatedAt: now, + Domains: []*domain.InstanceDomain{ + { + InstanceID: "instance-1", + Domain: "d1.example.com", + IsPrimary: gu.Ptr(true), + CreatedAt: yesterday, + }, + { + InstanceID: "instance-1", + Domain: "d2.example.com", + IsGenerated: gu.Ptr(true), + CreatedAt: yesterday, + }, + { + InstanceID: "instance-1", + Domain: "d3.example.com", + IsPrimary: gu.Ptr(true), + IsGenerated: gu.Ptr(false), + CreatedAt: yesterday, + }, + { + InstanceID: "instance-1", + Domain: "d4.example.com", + IsPrimary: gu.Ptr(false), + IsGenerated: gu.Ptr(true), + CreatedAt: yesterday, + }, + }, + } + + expectedInstance := &instance_v2beta.Instance{ + Id: "instance-1", + ChangeDate: timestamppb.New(now), + CreationDate: timestamppb.New(yesterday), + Name: "Instance One", + Version: "", + Domains: []*instance_v2beta.Domain{ + { + InstanceId: "instance-1", + CreationDate: timestamppb.New(yesterday), + Domain: "d1.example.com", + Primary: true, + Generated: false, + }, + { + InstanceId: "instance-1", + CreationDate: timestamppb.New(yesterday), + Domain: "d2.example.com", + Primary: false, + Generated: true, + }, + { + InstanceId: "instance-1", + CreationDate: timestamppb.New(yesterday), + Domain: "d3.example.com", + Primary: true, + Generated: false, + }, + { + InstanceId: "instance-1", + CreationDate: timestamppb.New(yesterday), + Domain: "d4.example.com", + Primary: false, + Generated: true, + }, + }, + } + + // Test + res := DomainInstanceModelToGRPCBetaResponse(inputInstance) + + // Verify + assert.Equal(t, expectedInstance, res) +} + func TestDomainInstanceModelToGRPCResponse(t *testing.T) { // Given t.Parallel() @@ -54,14 +144,14 @@ func TestDomainInstanceModelToGRPCResponse(t *testing.T) { }, } - expectedInstance := &instance.Instance{ + expectedInstance := &instance_v2.Instance{ Id: "instance-1", ChangeDate: timestamppb.New(now), CreationDate: timestamppb.New(yesterday), - State: instance.State_STATE_RUNNING, + State: instance_v2.State_STATE_RUNNING, Name: "Instance One", Version: "", - Domains: []*instance.Domain{ + CustomDomains: []*instance_v2.CustomDomain{ { InstanceId: "instance-1", CreationDate: timestamppb.New(yesterday), @@ -100,19 +190,19 @@ func TestDomainInstanceModelToGRPCResponse(t *testing.T) { assert.Equal(t, expectedInstance, res) } -func TestDomainInstanceListModelToGRPCResponse(t *testing.T) { +func TestDomainInstanceListModelToGRPCBetaResponse(t *testing.T) { t.Parallel() now := time.Now() tt := []struct { testName string inputResult []*domain.Instance - expectedResult []*instance.Instance + expectedResult []*instance_v2beta.Instance }{ { testName: "empty result", inputResult: []*domain.Instance{}, - expectedResult: []*instance.Instance{}, + expectedResult: []*instance_v2beta.Instance{}, }, { testName: "single instance without domains", @@ -125,14 +215,13 @@ func TestDomainInstanceListModelToGRPCResponse(t *testing.T) { Domains: nil, }, }, - expectedResult: []*instance.Instance{ + expectedResult: []*instance_v2beta.Instance{ { Id: "instance1", Name: "test-instance", CreationDate: timestamppb.New(now), ChangeDate: timestamppb.New(now), - State: instance.State_STATE_RUNNING, - Domains: []*instance.Domain{}, + Domains: []*instance_v2beta.Domain{}, }, }, }, @@ -162,14 +251,13 @@ func TestDomainInstanceListModelToGRPCResponse(t *testing.T) { Domains: nil, }, }, - expectedResult: []*instance.Instance{ + expectedResult: []*instance_v2beta.Instance{ { Id: "instance1", Name: "test-instance-1", CreationDate: timestamppb.New(now), ChangeDate: timestamppb.New(now), - State: instance.State_STATE_RUNNING, - Domains: []*instance.Domain{ + Domains: []*instance_v2beta.Domain{ { InstanceId: "instance1", Domain: "domain1.com", @@ -184,8 +272,110 @@ func TestDomainInstanceListModelToGRPCResponse(t *testing.T) { Name: "test-instance-2", CreationDate: timestamppb.New(now), ChangeDate: timestamppb.New(now), - State: instance.State_STATE_RUNNING, - Domains: []*instance.Domain{}, + Domains: []*instance_v2beta.Domain{}, + }, + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + // Test + result := DomainInstanceListModelToGRPCBetaResponse(tc.inputResult) + + // Verify + assert.Equal(t, tc.expectedResult, result) + }) + } +} + +func TestDomainInstanceListModelToGRPCResponse(t *testing.T) { + t.Parallel() + now := time.Now() + + tt := []struct { + testName string + inputResult []*domain.Instance + expectedResult []*instance_v2.Instance + }{ + { + testName: "empty result", + inputResult: []*domain.Instance{}, + expectedResult: []*instance_v2.Instance{}, + }, + { + testName: "single instance without domains", + inputResult: []*domain.Instance{ + { + ID: "instance1", + Name: "test-instance", + CreatedAt: now, + UpdatedAt: now, + Domains: nil, + }, + }, + expectedResult: []*instance_v2.Instance{ + { + Id: "instance1", + Name: "test-instance", + CreationDate: timestamppb.New(now), + ChangeDate: timestamppb.New(now), + State: instance_v2.State_STATE_RUNNING, + CustomDomains: []*instance_v2.CustomDomain{}, + }, + }, + }, + { + testName: "multiple instances with domains", + inputResult: []*domain.Instance{ + { + ID: "instance1", + Name: "test-instance-1", + CreatedAt: now, + UpdatedAt: now, + Domains: []*domain.InstanceDomain{ + { + InstanceID: "instance1", + Domain: "domain1.com", + CreatedAt: now, + IsPrimary: gu.Ptr(true), + IsGenerated: gu.Ptr(false), + }, + }, + }, + { + ID: "instance2", + Name: "test-instance-2", + CreatedAt: now, + UpdatedAt: now, + Domains: nil, + }, + }, + expectedResult: []*instance_v2.Instance{ + { + Id: "instance1", + Name: "test-instance-1", + CreationDate: timestamppb.New(now), + ChangeDate: timestamppb.New(now), + State: instance_v2.State_STATE_RUNNING, + CustomDomains: []*instance_v2.CustomDomain{ + { + InstanceId: "instance1", + Domain: "domain1.com", + CreationDate: timestamppb.New(now), + Primary: true, + Generated: false, + }, + }, + }, + { + Id: "instance2", + Name: "test-instance-2", + CreationDate: timestamppb.New(now), + ChangeDate: timestamppb.New(now), + State: instance_v2.State_STATE_RUNNING, + CustomDomains: []*instance_v2.CustomDomain{}, }, }, }, @@ -202,3 +392,140 @@ func TestDomainInstanceListModelToGRPCResponse(t *testing.T) { }) } } + +func TestListInstancesBetaRequestToV2Request(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + request *instance_v2beta.ListInstancesRequest + want *instance_v2.ListInstancesRequest + }{ + { + name: "empty request", + request: &instance_v2beta.ListInstancesRequest{ + Pagination: &filter_v2beta.PaginationRequest{}, + }, + want: &instance_v2.ListInstancesRequest{ + Pagination: &filter_v2.PaginationRequest{}, + SortingColumn: instance_v2.FieldName_FIELD_NAME_UNSPECIFIED, + Filters: []*instance_v2.Filter{}, + }, + }, + { + name: "request with all fields", + request: &instance_v2beta.ListInstancesRequest{ + Pagination: &filter_v2beta.PaginationRequest{ + Offset: 10, + Limit: 20, + Asc: true, + }, + SortingColumn: gu.Ptr(instance_v2beta.FieldName_FIELD_NAME_NAME), + Queries: []*instance_v2beta.Query{ + { + Query: &instance_v2beta.Query_DomainQuery{ + DomainQuery: &instance_v2beta.DomainsQuery{ + Domains: []string{"domain1.com", "domain2.com"}, + }, + }, + }, + { + Query: &instance_v2beta.Query_IdQuery{ + IdQuery: &instance_v2beta.IdsQuery{ + Ids: []string{"id1", "id2"}, + }, + }, + }, + }, + }, + want: &instance_v2.ListInstancesRequest{ + Pagination: &filter_v2.PaginationRequest{ + Offset: 10, + Limit: 20, + Asc: true, + }, + SortingColumn: instance_v2.FieldName_FIELD_NAME_NAME, + Filters: []*instance_v2.Filter{ + { + Filter: &instance_v2.Filter_CustomDomainsFilter{ + CustomDomainsFilter: &instance_v2.CustomDomainsFilter{ + Domains: []string{"domain1.com", "domain2.com"}, + }, + }, + }, + { + Filter: &instance_v2.Filter_InIdsFilter{ + InIdsFilter: &filter_v2.InIDsFilter{ + Ids: []string{"id1", "id2"}, + }, + }, + }, + }, + }, + }, + { + name: "request with different sorting field", + request: &instance_v2beta.ListInstancesRequest{ + Pagination: &filter_v2beta.PaginationRequest{}, + SortingColumn: gu.Ptr(instance_v2beta.FieldName_FIELD_NAME_CREATION_DATE), + }, + want: &instance_v2.ListInstancesRequest{ + Pagination: &filter_v2.PaginationRequest{}, + SortingColumn: instance_v2.FieldName_FIELD_NAME_CREATION_DATE, + Filters: []*instance_v2.Filter{}, + }, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got := ListInstancesBetaRequestToV2Request(tc.request) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestListInstancesBetaSortingColToV2Request(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + fieldName *instance_v2beta.FieldName + want instance_v2.FieldName + }{ + { + name: "nil field name", + fieldName: nil, + want: instance_v2.FieldName_FIELD_NAME_UNSPECIFIED, + }, + { + name: "creation date field", + fieldName: gu.Ptr(instance_v2beta.FieldName_FIELD_NAME_CREATION_DATE), + want: instance_v2.FieldName_FIELD_NAME_CREATION_DATE, + }, + { + name: "id field", + fieldName: gu.Ptr(instance_v2beta.FieldName_FIELD_NAME_ID), + want: instance_v2.FieldName_FIELD_NAME_ID, + }, + { + name: "name field", + fieldName: gu.Ptr(instance_v2beta.FieldName_FIELD_NAME_NAME), + want: instance_v2.FieldName_FIELD_NAME_NAME, + }, + { + name: "unspecified field", + fieldName: gu.Ptr(instance_v2beta.FieldName_FIELD_NAME_UNSPECIFIED), + want: instance_v2.FieldName_FIELD_NAME_UNSPECIFIED, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := listInstancesBetaSortingColToV2Request(tt.fieldName) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/backend/v3/api/instance/v2/domain.go b/backend/v3/api/instance/v2/domain.go new file mode 100644 index 000000000..c50ccd416 --- /dev/null +++ b/backend/v3/api/instance/v2/domain.go @@ -0,0 +1,287 @@ +package instancev2 + +import ( + "context" + + "connectrpc.com/connect" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/backend/v3/api/instance/v2/convert" + "github.com/zitadel/zitadel/backend/v3/domain" + "github.com/zitadel/zitadel/backend/v3/storage/database/repository" + "github.com/zitadel/zitadel/internal/api/authz" + filter_v2 "github.com/zitadel/zitadel/pkg/grpc/filter/v2" + filter_v2beta "github.com/zitadel/zitadel/pkg/grpc/filter/v2beta" + instance_v2 "github.com/zitadel/zitadel/pkg/grpc/instance/v2" + instance_v2beta "github.com/zitadel/zitadel/pkg/grpc/instance/v2beta" +) + +// ================= +// v2Beta endpoints +// ================= + +func AddCustomDomainBeta(ctx context.Context, request *connect.Request[instance_v2beta.AddCustomDomainRequest]) (*connect.Response[instance_v2beta.AddCustomDomainResponse], error) { + addCustomDomainCmd := domain.NewAddInstanceDomainCommand(request.Msg.GetInstanceId(), request.Msg.GetDomain(), domain.DomainTypeCustom) + oidcConfigUpdateCmd := domain.NewOIDCConfigurationUpdate(request.Msg.GetDomain(), authz.GetInstance(ctx).ProjectID(), authz.GetInstance(ctx).ConsoleApplicationID()) + + batchExec := domain.BatchExecutors( + addCustomDomainCmd, + oidcConfigUpdateCmd, + ) + + err := domain.Invoke( + ctx, + batchExec, + domain.WithInstanceDomainRepo(repository.InstanceDomainRepository()), + // domain.WithOIDCConfigurationRepo(repository.OIDCConfigurationRepository()), + ) + + if err != nil { + return nil, err + } + + return &connect.Response[instance_v2beta.AddCustomDomainResponse]{ + Msg: &instance_v2beta.AddCustomDomainResponse{ + // TODO(IAM-Marco): Return correct value. Tracked in https://github.com/zitadel/zitadel/issues/10881 + CreationDate: timestamppb.Now(), + }, + }, nil +} + +func RemoveCustomDomainBeta(ctx context.Context, request *connect.Request[instance_v2beta.RemoveCustomDomainRequest]) (*connect.Response[instance_v2beta.RemoveCustomDomainResponse], error) { + removeCustomDomainCmd := domain.NewRemoveInstanceDomainCommand(request.Msg.GetInstanceId(), request.Msg.GetDomain(), domain.DomainTypeCustom) + + err := domain.Invoke(ctx, removeCustomDomainCmd, domain.WithInstanceDomainRepo(repository.InstanceDomainRepository())) + if err != nil { + return nil, err + } + + var deletionDate *timestamppb.Timestamp + if removeCustomDomainCmd.DeleteTime != nil { + deletionDate = timestamppb.New(*removeCustomDomainCmd.DeleteTime) + } + return &connect.Response[instance_v2beta.RemoveCustomDomainResponse]{ + Msg: &instance_v2beta.RemoveCustomDomainResponse{ + // TODO(IAM-Marco): Return correct value. Tracked in https://github.com/zitadel/zitadel/issues/10881 + DeletionDate: deletionDate, + }, + }, nil +} + +func ListCustomDomainsBeta(ctx context.Context, request *connect.Request[instance_v2beta.ListCustomDomainsRequest]) (*connect.Response[instance_v2beta.ListCustomDomainsResponse], error) { + listCustomDomainsQuery := domain.NewListInstanceDomainsQuery(convert.ListCustomDomainsBetaRequestToV2Request(request.Msg)) + + err := domain.Invoke(ctx, listCustomDomainsQuery, domain.WithInstanceDomainRepo(repository.InstanceDomainRepository())) + if err != nil { + return nil, err + } + + customDomains := listCustomDomainsQuery.Result() + return &connect.Response[instance_v2beta.ListCustomDomainsResponse]{ + Msg: &instance_v2beta.ListCustomDomainsResponse{ + Domains: convert.DomainInstanceDomainListModelToGRPCBetaResponse(customDomains), + Pagination: &filter_v2beta.PaginationResponse{ + // TODO(IAM-Marco): return correct value. Tracked in https://github.com/zitadel/zitadel/issues/10955 + TotalResult: uint64(len(customDomains)), + AppliedLimit: uint64(request.Msg.GetPagination().GetLimit()), + }, + }, + }, nil +} + +func AddTrustedDomainBeta(ctx context.Context, request *connect.Request[instance_v2beta.AddTrustedDomainRequest]) (*connect.Response[instance_v2beta.AddTrustedDomainResponse], error) { + addTrustedDomainCmd := domain.NewAddInstanceDomainCommand(request.Msg.GetInstanceId(), request.Msg.GetDomain(), domain.DomainTypeTrusted) + + err := domain.Invoke( + ctx, + addTrustedDomainCmd, + domain.WithInstanceDomainRepo(repository.InstanceDomainRepository()), + ) + + if err != nil { + return nil, err + } + + return &connect.Response[instance_v2beta.AddTrustedDomainResponse]{ + Msg: &instance_v2beta.AddTrustedDomainResponse{ + // TODO(IAM-Marco): Return correct value. Tracked in https://github.com/zitadel/zitadel/issues/10881 + CreationDate: timestamppb.Now(), + }, + }, nil +} + +func RemoveTrustedDomainBeta(ctx context.Context, request *connect.Request[instance_v2beta.RemoveTrustedDomainRequest]) (*connect.Response[instance_v2beta.RemoveTrustedDomainResponse], error) { + removeTrustedDomainCmd := domain.NewRemoveInstanceDomainCommand(request.Msg.GetInstanceId(), request.Msg.GetDomain(), domain.DomainTypeTrusted) + + err := domain.Invoke(ctx, removeTrustedDomainCmd, domain.WithInstanceDomainRepo(repository.InstanceDomainRepository())) + if err != nil { + return nil, err + } + + var deletionDate *timestamppb.Timestamp + if removeTrustedDomainCmd.DeleteTime != nil { + deletionDate = timestamppb.New(*removeTrustedDomainCmd.DeleteTime) + } + return &connect.Response[instance_v2beta.RemoveTrustedDomainResponse]{ + Msg: &instance_v2beta.RemoveTrustedDomainResponse{ + // TODO(IAM-Marco): Return correct value. Tracked in https://github.com/zitadel/zitadel/issues/10881 + DeletionDate: deletionDate, + }, + }, nil +} + +func ListTrustedDomainsBeta(ctx context.Context, request *connect.Request[instance_v2beta.ListTrustedDomainsRequest]) (*connect.Response[instance_v2beta.ListTrustedDomainsResponse], error) { + listTrustedDomainsQuery := domain.NewListInstanceTrustedDomainsQuery(convert.ListTrustedDomainsBetaRequestToV2Request(request.Msg)) + + err := domain.Invoke(ctx, listTrustedDomainsQuery, domain.WithInstanceDomainRepo(repository.InstanceDomainRepository())) + if err != nil { + return nil, err + } + + trustedDomains := listTrustedDomainsQuery.Result() + return &connect.Response[instance_v2beta.ListTrustedDomainsResponse]{ + Msg: &instance_v2beta.ListTrustedDomainsResponse{ + TrustedDomain: convert.TrustedDomainInstanceDomainListModelToGRPCBetaResponse(trustedDomains), + Pagination: &filter_v2beta.PaginationResponse{ + // TODO(IAM-Marco): return correct value. Tracked in https://github.com/zitadel/zitadel/issues/10955 + TotalResult: uint64(len(trustedDomains)), + AppliedLimit: uint64(request.Msg.GetPagination().GetLimit()), + }, + }, + }, nil +} + +// ================= +// v2 endpoints +// ================= + +func AddCustomDomain(ctx context.Context, request *connect.Request[instance_v2.AddCustomDomainRequest]) (*connect.Response[instance_v2.AddCustomDomainResponse], error) { + addCustomDomainCmd := domain.NewAddInstanceDomainCommand(request.Msg.GetInstanceId(), request.Msg.GetCustomDomain(), domain.DomainTypeCustom) + oidcConfigUpdateCmd := domain.NewOIDCConfigurationUpdate(request.Msg.GetCustomDomain(), authz.GetInstance(ctx).ProjectID(), authz.GetInstance(ctx).ConsoleApplicationID()) + + batchExec := domain.BatchExecutors( + addCustomDomainCmd, + oidcConfigUpdateCmd, + ) + + err := domain.Invoke( + ctx, + batchExec, + domain.WithInstanceDomainRepo(repository.InstanceDomainRepository()), + // domain.WithOIDCConfigurationRepo(repository.OIDCConfigurationRepository()), + ) + + if err != nil { + return nil, err + } + + return &connect.Response[instance_v2.AddCustomDomainResponse]{ + Msg: &instance_v2.AddCustomDomainResponse{ + // TODO(IAM-Marco): Return correct value. Tracked in https://github.com/zitadel/zitadel/issues/10881 + CreationDate: timestamppb.Now(), + }, + }, nil +} + +func RemoveCustomDomain(ctx context.Context, request *connect.Request[instance_v2.RemoveCustomDomainRequest]) (*connect.Response[instance_v2.RemoveCustomDomainResponse], error) { + removeCustomDomainCmd := domain.NewRemoveInstanceDomainCommand(request.Msg.GetInstanceId(), request.Msg.GetCustomDomain(), domain.DomainTypeCustom) + + err := domain.Invoke(ctx, removeCustomDomainCmd, domain.WithInstanceDomainRepo(repository.InstanceDomainRepository())) + if err != nil { + return nil, err + } + + var deletionDate *timestamppb.Timestamp + if removeCustomDomainCmd.DeleteTime != nil { + deletionDate = timestamppb.New(*removeCustomDomainCmd.DeleteTime) + } + return &connect.Response[instance_v2.RemoveCustomDomainResponse]{ + Msg: &instance_v2.RemoveCustomDomainResponse{ + // TODO(IAM-Marco): Return correct value. Tracked in https://github.com/zitadel/zitadel/issues/10881 + DeletionDate: deletionDate, + }, + }, nil +} + +func ListCustomDomains(ctx context.Context, request *connect.Request[instance_v2.ListCustomDomainsRequest]) (*connect.Response[instance_v2.ListCustomDomainsResponse], error) { + listCustomDomainsQuery := domain.NewListInstanceDomainsQuery(request.Msg) + + err := domain.Invoke(ctx, listCustomDomainsQuery, domain.WithInstanceDomainRepo(repository.InstanceDomainRepository())) + if err != nil { + return nil, err + } + + customDomains := listCustomDomainsQuery.Result() + return &connect.Response[instance_v2.ListCustomDomainsResponse]{ + Msg: &instance_v2.ListCustomDomainsResponse{ + Domains: convert.DomainInstanceDomainListModelToGRPCResponse(customDomains), + Pagination: &filter_v2.PaginationResponse{ + // TODO(IAM-Marco): return correct value. Tracked in https://github.com/zitadel/zitadel/issues/10955 + TotalResult: uint64(len(customDomains)), + AppliedLimit: uint64(request.Msg.GetPagination().GetLimit()), + }, + }, + }, nil +} + +func AddTrustedDomain(ctx context.Context, request *connect.Request[instance_v2.AddTrustedDomainRequest]) (*connect.Response[instance_v2.AddTrustedDomainResponse], error) { + addTrustedDomainCmd := domain.NewAddInstanceDomainCommand(request.Msg.GetInstanceId(), request.Msg.GetTrustedDomain(), domain.DomainTypeTrusted) + + err := domain.Invoke( + ctx, + addTrustedDomainCmd, + domain.WithInstanceDomainRepo(repository.InstanceDomainRepository()), + ) + + if err != nil { + return nil, err + } + + return &connect.Response[instance_v2.AddTrustedDomainResponse]{ + Msg: &instance_v2.AddTrustedDomainResponse{ + // TODO(IAM-Marco): Return correct value. Tracked in https://github.com/zitadel/zitadel/issues/10881 + CreationDate: timestamppb.Now(), + }, + }, nil +} + +func RemoveTrustedDomain(ctx context.Context, request *connect.Request[instance_v2.RemoveTrustedDomainRequest]) (*connect.Response[instance_v2.RemoveTrustedDomainResponse], error) { + removeTrustedDomainCmd := domain.NewRemoveInstanceDomainCommand(request.Msg.GetInstanceId(), request.Msg.GetTrustedDomain(), domain.DomainTypeTrusted) + + err := domain.Invoke(ctx, removeTrustedDomainCmd, domain.WithInstanceDomainRepo(repository.InstanceDomainRepository())) + if err != nil { + return nil, err + } + + var deletionDate *timestamppb.Timestamp + if removeTrustedDomainCmd.DeleteTime != nil { + deletionDate = timestamppb.New(*removeTrustedDomainCmd.DeleteTime) + } + return &connect.Response[instance_v2.RemoveTrustedDomainResponse]{ + Msg: &instance_v2.RemoveTrustedDomainResponse{ + // TODO(IAM-Marco): Return correct value. Tracked in https://github.com/zitadel/zitadel/issues/10881 + DeletionDate: deletionDate, + }, + }, nil +} + +func ListTrustedDomains(ctx context.Context, request *connect.Request[instance_v2.ListTrustedDomainsRequest]) (*connect.Response[instance_v2.ListTrustedDomainsResponse], error) { + listTrustedDomainsQuery := domain.NewListInstanceTrustedDomainsQuery(request.Msg) + + err := domain.Invoke(ctx, listTrustedDomainsQuery, domain.WithInstanceDomainRepo(repository.InstanceDomainRepository())) + if err != nil { + return nil, err + } + + trustedDomains := listTrustedDomainsQuery.Result() + return &connect.Response[instance_v2.ListTrustedDomainsResponse]{ + Msg: &instance_v2.ListTrustedDomainsResponse{ + TrustedDomain: convert.TrustedDomainInstanceDomainListModelToGRPCResponse(trustedDomains), + Pagination: &filter_v2.PaginationResponse{ + // TODO(IAM-Marco): return correct value. Tracked in https://github.com/zitadel/zitadel/issues/10955 + TotalResult: uint64(len(trustedDomains)), + AppliedLimit: uint64(request.Msg.GetPagination().GetLimit()), + }, + }, + }, nil +} diff --git a/backend/v3/api/instance/v2/instance.go b/backend/v3/api/instance/v2/instance.go index 0716529b9..faa6383e9 100644 --- a/backend/v3/api/instance/v2/instance.go +++ b/backend/v3/api/instance/v2/instance.go @@ -2,61 +2,62 @@ package instancev2 import ( "context" - "errors" "connectrpc.com/connect" "google.golang.org/protobuf/types/known/timestamppb" "github.com/zitadel/zitadel/backend/v3/api/instance/v2/convert" "github.com/zitadel/zitadel/backend/v3/domain" - "github.com/zitadel/zitadel/backend/v3/storage/database" "github.com/zitadel/zitadel/backend/v3/storage/database/repository" - "github.com/zitadel/zitadel/internal/zerrors" - filter "github.com/zitadel/zitadel/pkg/grpc/filter/v2beta" - instance "github.com/zitadel/zitadel/pkg/grpc/instance/v2beta" + filter_v2 "github.com/zitadel/zitadel/pkg/grpc/filter/v2" + filter_v2beta "github.com/zitadel/zitadel/pkg/grpc/filter/v2beta" + instance_v2 "github.com/zitadel/zitadel/pkg/grpc/instance/v2" + instance_v2beta "github.com/zitadel/zitadel/pkg/grpc/instance/v2beta" ) -func DeleteInstance(ctx context.Context, request *connect.Request[instance.DeleteInstanceRequest]) (*connect.Response[instance.DeleteInstanceResponse], error) { +// ================= +// v2Beta endpoints +// ================= + +func DeleteInstanceBeta(ctx context.Context, request *connect.Request[instance_v2beta.DeleteInstanceRequest]) (*connect.Response[instance_v2beta.DeleteInstanceResponse], error) { instanceDeleteCmd := domain.NewDeleteInstanceCommand(request.Msg.GetInstanceId()) err := domain.Invoke(ctx, instanceDeleteCmd, domain.WithInstanceRepo(repository.InstanceRepository())) if err != nil { - if errors.Is(err, &database.NoRowFoundError{}) { - return nil, zerrors.ThrowNotFound(err, "INST-QVrUwc", "instance not found") - } return nil, err } - return &connect.Response[instance.DeleteInstanceResponse]{ - Msg: &instance.DeleteInstanceResponse{ + var deletionDate *timestamppb.Timestamp + if instanceDeleteCmd.DeleteTime != nil { + deletionDate = timestamppb.New(*instanceDeleteCmd.DeleteTime) + } + return &connect.Response[instance_v2beta.DeleteInstanceResponse]{ + Msg: &instance_v2beta.DeleteInstanceResponse{ // TODO(IAM-Marco): Change this with the real update date when OrganizationRepo.Update() // returns the timestamp - DeletionDate: timestamppb.Now(), + DeletionDate: deletionDate, }, }, nil } -func GetInstance(ctx context.Context, request *connect.Request[instance.GetInstanceRequest]) (*connect.Response[instance.GetInstanceResponse], error) { +func GetInstanceBeta(ctx context.Context, request *connect.Request[instance_v2beta.GetInstanceRequest]) (*connect.Response[instance_v2beta.GetInstanceResponse], error) { instanceGetCmd := domain.NewGetInstanceCommand(request.Msg.GetInstanceId()) err := domain.Invoke(ctx, instanceGetCmd, domain.WithInstanceRepo(repository.InstanceRepository())) if err != nil { - if errors.Is(err, &database.NoRowFoundError{}) { - return nil, zerrors.ThrowNotFound(err, "INST-QVrUwc", "instance not found") - } return nil, err } - return &connect.Response[instance.GetInstanceResponse]{ - Msg: &instance.GetInstanceResponse{ - Instance: convert.DomainInstanceModelToGRPCResponse(instanceGetCmd.Result()), + return &connect.Response[instance_v2beta.GetInstanceResponse]{ + Msg: &instance_v2beta.GetInstanceResponse{ + Instance: convert.DomainInstanceModelToGRPCBetaResponse(instanceGetCmd.Result()), }, }, nil } -func UpdateInstance(ctx context.Context, request *connect.Request[instance.UpdateInstanceRequest]) (*connect.Response[instance.UpdateInstanceResponse], error) { +func UpdateInstanceBeta(ctx context.Context, request *connect.Request[instance_v2beta.UpdateInstanceRequest]) (*connect.Response[instance_v2beta.UpdateInstanceResponse], error) { instanceUpdateCmd := domain.NewUpdateInstanceCommand(request.Msg.GetInstanceId(), request.Msg.GetInstanceName()) err := domain.Invoke(ctx, instanceUpdateCmd, domain.WithInstanceRepo(repository.InstanceRepository())) @@ -65,8 +66,8 @@ func UpdateInstance(ctx context.Context, request *connect.Request[instance.Updat return nil, err } - return &connect.Response[instance.UpdateInstanceResponse]{ - Msg: &instance.UpdateInstanceResponse{ + return &connect.Response[instance_v2beta.UpdateInstanceResponse]{ + Msg: &instance_v2beta.UpdateInstanceResponse{ // TODO(IAM-Marco): Change this with the real update date when InstanceRepo.Update() // returns the timestamp ChangeDate: timestamppb.Now(), @@ -74,7 +75,93 @@ func UpdateInstance(ctx context.Context, request *connect.Request[instance.Updat }, nil } -func ListInstances(ctx context.Context, request *connect.Request[instance.ListInstancesRequest]) (*connect.Response[instance.ListInstancesResponse], error) { +func ListInstancesBeta(ctx context.Context, request *connect.Request[instance_v2beta.ListInstancesRequest]) (*connect.Response[instance_v2beta.ListInstancesResponse], error) { + instancesListCmd := domain.NewListInstancesCommand(convert.ListInstancesBetaRequestToV2Request(request.Msg)) + + err := domain.Invoke( + ctx, + instancesListCmd, + domain.WithInstanceRepo(repository.InstanceRepository()), + domain.WithInstanceDomainRepo(repository.InstanceDomainRepository()), + ) + if err != nil { + return nil, err + } + + instances := instancesListCmd.Result() + return &connect.Response[instance_v2beta.ListInstancesResponse]{ + Msg: &instance_v2beta.ListInstancesResponse{ + Instances: convert.DomainInstanceListModelToGRPCBetaResponse(instances), + Pagination: &filter_v2beta.PaginationResponse{ + // TODO(IAM-Marco): return correct value. Tracked in https://github.com/zitadel/zitadel/issues/10955 + TotalResult: uint64(len(instances)), + AppliedLimit: uint64(request.Msg.GetPagination().GetLimit()), + }, + }, + }, nil +} + +// ================= +// v2 endpoints +// ================= + +func DeleteInstance(ctx context.Context, request *connect.Request[instance_v2.DeleteInstanceRequest]) (*connect.Response[instance_v2.DeleteInstanceResponse], error) { + instanceDeleteCmd := domain.NewDeleteInstanceCommand(request.Msg.GetInstanceId()) + + err := domain.Invoke(ctx, instanceDeleteCmd, domain.WithInstanceRepo(repository.InstanceRepository())) + + if err != nil { + return nil, err + } + + var deletionDate *timestamppb.Timestamp + if instanceDeleteCmd.DeleteTime != nil { + deletionDate = timestamppb.New(*instanceDeleteCmd.DeleteTime) + } + return &connect.Response[instance_v2.DeleteInstanceResponse]{ + Msg: &instance_v2.DeleteInstanceResponse{ + // TODO(IAM-Marco): Change this with the real update date when OrganizationRepo.Update() + // returns the timestamp + DeletionDate: deletionDate, + }, + }, nil +} + +func GetInstance(ctx context.Context, request *connect.Request[instance_v2.GetInstanceRequest]) (*connect.Response[instance_v2.GetInstanceResponse], error) { + instanceGetCmd := domain.NewGetInstanceCommand(request.Msg.GetInstanceId()) + + err := domain.Invoke(ctx, instanceGetCmd, domain.WithInstanceRepo(repository.InstanceRepository())) + + if err != nil { + return nil, err + } + + return &connect.Response[instance_v2.GetInstanceResponse]{ + Msg: &instance_v2.GetInstanceResponse{ + Instance: convert.DomainInstanceModelToGRPCResponse(instanceGetCmd.Result()), + }, + }, nil +} + +func UpdateInstance(ctx context.Context, request *connect.Request[instance_v2.UpdateInstanceRequest]) (*connect.Response[instance_v2.UpdateInstanceResponse], error) { + instanceUpdateCmd := domain.NewUpdateInstanceCommand(request.Msg.GetInstanceId(), request.Msg.GetInstanceName()) + + err := domain.Invoke(ctx, instanceUpdateCmd, domain.WithInstanceRepo(repository.InstanceRepository())) + + if err != nil { + return nil, err + } + + return &connect.Response[instance_v2.UpdateInstanceResponse]{ + Msg: &instance_v2.UpdateInstanceResponse{ + // TODO(IAM-Marco): Change this with the real update date when InstanceRepo.Update() + // returns the timestamp + ChangeDate: timestamppb.Now(), + }, + }, nil +} + +func ListInstances(ctx context.Context, request *connect.Request[instance_v2.ListInstancesRequest]) (*connect.Response[instance_v2.ListInstancesResponse], error) { instancesListCmd := domain.NewListInstancesCommand(request.Msg) err := domain.Invoke( @@ -88,10 +175,10 @@ func ListInstances(ctx context.Context, request *connect.Request[instance.ListIn } instances := instancesListCmd.Result() - return &connect.Response[instance.ListInstancesResponse]{ - Msg: &instance.ListInstancesResponse{ + return &connect.Response[instance_v2.ListInstancesResponse]{ + Msg: &instance_v2.ListInstancesResponse{ Instances: convert.DomainInstanceListModelToGRPCResponse(instances), - Pagination: &filter.PaginationResponse{ + Pagination: &filter_v2.PaginationResponse{ // TODO(IAM-Marco): return correct value. Tracked in https://github.com/zitadel/zitadel/issues/10955 TotalResult: uint64(len(instances)), AppliedLimit: uint64(request.Msg.GetPagination().GetLimit()), diff --git a/backend/v3/api/org/v2/org.go b/backend/v3/api/org/v2/org.go index 7665e3297..686ab68f2 100644 --- a/backend/v3/api/org/v2/org.go +++ b/backend/v3/api/org/v2/org.go @@ -17,39 +17,11 @@ import ( v2beta_org "github.com/zitadel/zitadel/pkg/grpc/org/v2beta" ) -// import ( -// "context" +// ================= +// v2Beta endpoints +// ================= -// "github.com/zitadel/zitadel/backend/v3/domain" -// "github.com/zitadel/zitadel/pkg/grpc/org/v2" -// ) - -// func CreateOrg(ctx context.Context, req *org.AddOrganizationRequest) (resp *org.AddOrganizationResponse, err error) { -// cmd := domain.NewAddOrgCommand( -// req.GetName(), -// addOrgAdminToCommand(req.GetAdmins()...)..., -// ) -// err = domain.Invoke(ctx, cmd) -// if err != nil { -// return nil, err -// } -// return &org.AddOrganizationResponse{ -// OrganizationId: cmd.ID, -// }, nil -// } - -// func addOrgAdminToCommand(admins ...*org.AddOrganizationRequest_Admin) []*domain.AddMemberCommand { -// cmds := make([]*domain.AddMemberCommand, len(admins)) -// for i, admin := range admins { -// cmds[i] = &domain.AddMemberCommand{ -// UserID: admin.GetUserId(), -// Roles: admin.GetRoles(), -// } -// } -// return cmds -// } - -func UpdateOrganization(ctx context.Context, request *connect.Request[v2beta_org.UpdateOrganizationRequest]) (*connect.Response[v2beta_org.UpdateOrganizationResponse], error) { +func UpdateOrganizationBeta(ctx context.Context, request *connect.Request[v2beta_org.UpdateOrganizationRequest]) (*connect.Response[v2beta_org.UpdateOrganizationResponse], error) { orgUpdateCmd := domain.NewUpdateOrgCommand(request.Msg.GetId(), request.Msg.GetName()) // TODO(IAM-Marco) Finish implementation in https://github.com/zitadel/zitadel/issues/10447 @@ -73,30 +45,6 @@ func UpdateOrganization(ctx context.Context, request *connect.Request[v2beta_org }, nil } -func ListOrganizations(ctx context.Context, request *connect.Request[v2_org.ListOrganizationsRequest]) (*connect.Response[v2_org.ListOrganizationsResponse], error) { - orgListQuery := domain.NewListOrgsQuery(request.Msg) - - err := domain.Invoke(ctx, orgListQuery, - domain.WithOrganizationRepo(repository.OrganizationRepository()), - domain.WithOrganizationDomainRepo(repository.OrganizationDomainRepository()), - ) - if err != nil { - return nil, err - } - - orgs := convert.DomainOrganizationListModelToGRPCResponse(orgListQuery.Result()) - return &connect.Response[v2_org.ListOrganizationsResponse]{ - Msg: &v2_org.ListOrganizationsResponse{ - Result: orgs, - Details: &object.ListDetails{ - // TODO(IAM-Marco): Return correct result once permissions are in place - TotalResult: uint64(len(orgs)), - }, - SortingColumn: request.Msg.GetSortingColumn(), - }, - }, nil -} - // TODO(IAM-Marco): Remove in V5 (see https://github.com/zitadel/zitadel/issues/10877) func ListOrganizationsBeta(ctx context.Context, request *connect.Request[v2beta_org.ListOrganizationsRequest]) (*connect.Response[v2beta_org.ListOrganizationsResponse], error) { orgListQuery := domain.NewListOrgsQuery(convert.OrganizationBetaRequestToV2Request(request.Msg)) @@ -121,7 +69,7 @@ func ListOrganizationsBeta(ctx context.Context, request *connect.Request[v2beta_ }, nil } -func DeleteOrganization(ctx context.Context, request *connect.Request[v2beta_org.DeleteOrganizationRequest]) (*connect.Response[v2beta_org.DeleteOrganizationResponse], error) { +func DeleteOrganizationBeta(ctx context.Context, request *connect.Request[v2beta_org.DeleteOrganizationRequest]) (*connect.Response[v2beta_org.DeleteOrganizationResponse], error) { orgDeleteCmd := domain.NewDeleteOrgCommand(request.Msg.GetId()) err := domain.Invoke(ctx, orgDeleteCmd, @@ -145,7 +93,7 @@ func DeleteOrganization(ctx context.Context, request *connect.Request[v2beta_org }, nil } -func DeactivateOrganization(ctx context.Context, request *connect.Request[v2beta_org.DeactivateOrganizationRequest]) (*connect.Response[v2beta_org.DeactivateOrganizationResponse], error) { +func DeactivateOrganizationBeta(ctx context.Context, request *connect.Request[v2beta_org.DeactivateOrganizationRequest]) (*connect.Response[v2beta_org.DeactivateOrganizationResponse], error) { orgDeactivateCmd := domain.NewDeactivateOrgCommand(request.Msg.GetId()) err := domain.Invoke(ctx, orgDeactivateCmd, @@ -164,7 +112,7 @@ func DeactivateOrganization(ctx context.Context, request *connect.Request[v2beta }, nil } -func ActivateOrganization(ctx context.Context, request *connect.Request[v2beta_org.ActivateOrganizationRequest]) (*connect.Response[v2beta_org.ActivateOrganizationResponse], error) { +func ActivateOrganizationBeta(ctx context.Context, request *connect.Request[v2beta_org.ActivateOrganizationRequest]) (*connect.Response[v2beta_org.ActivateOrganizationResponse], error) { orgActivateCmd := domain.NewActivateOrgCommand(request.Msg.GetId()) err := domain.Invoke(ctx, orgActivateCmd, @@ -182,3 +130,31 @@ func ActivateOrganization(ctx context.Context, request *connect.Request[v2beta_o }, }, nil } + +// ================= +// v2 endpoints +// ================= + +func ListOrganizations(ctx context.Context, request *connect.Request[v2_org.ListOrganizationsRequest]) (*connect.Response[v2_org.ListOrganizationsResponse], error) { + orgListQuery := domain.NewListOrgsQuery(request.Msg) + + err := domain.Invoke(ctx, orgListQuery, + domain.WithOrganizationRepo(repository.OrganizationRepository()), + domain.WithOrganizationDomainRepo(repository.OrganizationDomainRepository()), + ) + if err != nil { + return nil, err + } + + orgs := convert.DomainOrganizationListModelToGRPCResponse(orgListQuery.Result()) + return &connect.Response[v2_org.ListOrganizationsResponse]{ + Msg: &v2_org.ListOrganizationsResponse{ + Result: orgs, + Details: &object.ListDetails{ + // TODO(IAM-Marco): Return correct result once permissions are in place + TotalResult: uint64(len(orgs)), + }, + SortingColumn: request.Msg.GetSortingColumn(), + }, + }, nil +} diff --git a/backend/v3/domain/instance_delete.go b/backend/v3/domain/instance_delete.go index 4df3279a7..1cbadf122 100644 --- a/backend/v3/domain/instance_delete.go +++ b/backend/v3/domain/instance_delete.go @@ -2,7 +2,11 @@ package domain import ( "context" + "errors" "strings" + "time" + + "github.com/muhlemmer/gu" "github.com/zitadel/zitadel/backend/v3/storage/database" "github.com/zitadel/zitadel/internal/eventstore" @@ -20,6 +24,8 @@ type DeleteInstanceCommand struct { // InstanceName is public for testing purposes // do not use this field InstanceName string + + DeleteTime *time.Time } // RequiresTransaction implements [Transactional]. @@ -46,6 +52,9 @@ func (d *DeleteInstanceCommand) Execute(ctx context.Context, opts *InvokeOpts) ( instanceToDelete, err := instanceRepo.Get(ctx, opts.DB(), database.WithCondition(instanceRepo.IDCondition(d.ID))) if err != nil { + if errors.Is(err, &database.NoRowFoundError{}) { + return nil + } return err } @@ -58,18 +67,22 @@ func (d *DeleteInstanceCommand) Execute(ctx context.Context, opts *InvokeOpts) ( deletedRows, err := instanceRepo.Delete(ctx, opts.DB(), instanceToDelete.ID) if err != nil { - return err + if errors.Is(err, &database.NoRowFoundError{}) { + return nil + } + return zerrors.ThrowInternal(err, "DOM-caF4Vs", "Errors.Instance.Delete") } if deletedRows > 1 { - err = zerrors.ThrowInternalf(nil, "DOM-Od04Jx", "expecting 1 row deleted, got %d", deletedRows) - return err + return zerrors.ThrowInternal(nil, "DOM-Od04Jx", "Errors.Instsance.DeleteMismatch") } if deletedRows < 1 { - err = zerrors.ThrowNotFound(nil, "DOM-daglwD", "instance not found") + return nil } + d.DeleteTime = gu.Ptr(time.Now()) + return err } @@ -85,7 +98,7 @@ func (d *DeleteInstanceCommand) Validate(ctx context.Context, opts *InvokeOpts) } if authZErr := opts.Permissions.CheckInstancePermission(ctx, InstanceWritePermission); authZErr != nil { - return zerrors.ThrowPermissionDenied(authZErr, "DOM-Yz8f1X", "permission denied") + return zerrors.ThrowPermissionDenied(authZErr, "DOM-Yz8f1X", "Errors.PermissionDenied") } return nil diff --git a/backend/v3/domain/instance_delete_test.go b/backend/v3/domain/instance_delete_test.go index 0a660da20..9c924c0e7 100644 --- a/backend/v3/domain/instance_delete_test.go +++ b/backend/v3/domain/instance_delete_test.go @@ -52,7 +52,7 @@ func TestDeleteInstanceCommand_Validate(t *testing.T) { return permChecker }, - expectedError: zerrors.ThrowPermissionDenied(permissionErr, "DOM-Yz8f1X", "permission denied"), + expectedError: zerrors.ThrowPermissionDenied(permissionErr, "DOM-Yz8f1X", "Errors.PermissionDenied"), inputID: "instance-1", }, { @@ -116,6 +116,7 @@ func TestDeleteInstanceCommand_Execute(t *testing.T) { ctx := authz.NewMockContext("inst-1", "org-1", gofakeit.UUID()) deleteErr := errors.New("delete error") getErr := errors.New("get error") + noRowFoundErr := database.NewNoRowFoundError(nil) tests := []struct { testName string @@ -126,12 +127,37 @@ func TestDeleteInstanceCommand_Execute(t *testing.T) { expectedError error expectedInstanceName string expectedInstanceDomains []string + expectsDeleteTime bool }{ { testName: "when retrieving instance fails should return error", instanceRepo: func(ctrl *gomock.Controller) domain.InstanceRepository { instanceRepo := domainmock.NewInstanceRepo(ctrl) + instanceRepo.EXPECT(). + LoadDomains(). + Times(1). + Return(instanceRepo) + + instanceRepo.EXPECT(). + Get( + gomock.Any(), + gomock.Any(), + dbmock.QueryOptions(database.WithCondition( + instanceRepo.IDCondition("instance-1"), + )), + ). + Times(1). + Return(nil, noRowFoundErr) + return instanceRepo + }, + inputInstanceID: "instance-1", + }, + { + testName: "when retrieving instance fails with no row found should return no error", + instanceRepo: func(ctrl *gomock.Controller) domain.InstanceRepository { + instanceRepo := domainmock.NewInstanceRepo(ctrl) + instanceRepo.EXPECT(). LoadDomains(). Times(1). @@ -188,7 +214,46 @@ func TestDeleteInstanceCommand_Execute(t *testing.T) { return instanceRepo }, inputInstanceID: "instance-1", - expectedError: deleteErr, + expectedError: zerrors.ThrowInternal(deleteErr, "DOM-caF4Vs", "Errors.Instance.Delete"), + expectedInstanceName: "My instance 1", + expectedInstanceDomains: []string{"d1.example.com", "d2.example.com", "d3.example.com"}, + }, + { + testName: "when delete instance fails with no row found should return no error", + instanceRepo: func(ctrl *gomock.Controller) domain.InstanceRepository { + instanceRepo := domainmock.NewInstanceRepo(ctrl) + + instanceRepo.EXPECT(). + LoadDomains(). + Times(1). + Return(instanceRepo) + + instanceRepo.EXPECT(). + Get( + gomock.Any(), + gomock.Any(), + dbmock.QueryOptions(database.WithCondition( + instanceRepo.IDCondition("instance-1"), + )), + ). + Times(1). + Return(&domain.Instance{ + ID: "instance-1", + Name: "My instance 1", + Domains: []*domain.InstanceDomain{ + {Domain: "d1.example.com"}, + {Domain: "d2.example.com"}, + {Domain: "d3.example.com"}, + }, + }, nil) + + instanceRepo.EXPECT(). + Delete(gomock.Any(), gomock.Any(), "instance-1"). + Times(1). + Return(int64(0), database.NewNoRowFoundError(nil)) + return instanceRepo + }, + inputInstanceID: "instance-1", expectedInstanceName: "My instance 1", expectedInstanceDomains: []string{"d1.example.com", "d2.example.com", "d3.example.com"}, }, @@ -228,12 +293,12 @@ func TestDeleteInstanceCommand_Execute(t *testing.T) { return instanceRepo }, inputInstanceID: "instance-1", - expectedError: zerrors.ThrowInternalf(nil, "DOM-Od04Jx", "expecting 1 row deleted, got %d", 2), + expectedError: zerrors.ThrowInternal(nil, "DOM-Od04Jx", "Errors.Instsance.DeleteMismatch"), expectedInstanceName: "My instance 1", expectedInstanceDomains: []string{"d1.example.com", "d2.example.com", "d3.example.com"}, }, { - testName: "when no rows deleted should return not found error", + testName: "when no rows deleted should return no error", instanceRepo: func(ctrl *gomock.Controller) domain.InstanceRepository { instanceRepo := domainmock.NewInstanceRepo(ctrl) @@ -268,7 +333,6 @@ func TestDeleteInstanceCommand_Execute(t *testing.T) { return instanceRepo }, inputInstanceID: "instance-1", - expectedError: zerrors.ThrowNotFound(nil, "DOM-daglwD", "instance not found"), expectedInstanceName: "My instance 1", expectedInstanceDomains: []string{"d1.example.com", "d2.example.com", "d3.example.com"}, }, @@ -310,6 +374,7 @@ func TestDeleteInstanceCommand_Execute(t *testing.T) { inputInstanceID: "instance-1", expectedInstanceName: "My instance 1", expectedInstanceDomains: []string{"d1.example.com", "d2.example.com", "d3.example.com"}, + expectsDeleteTime: true, }, } @@ -335,6 +400,7 @@ func TestDeleteInstanceCommand_Execute(t *testing.T) { assert.Equal(t, tc.expectedError, err) assert.Equal(t, tc.expectedInstanceName, d.InstanceName) assert.ElementsMatch(t, tc.expectedInstanceDomains, d.InstanceDomains) + assert.Equal(t, tc.expectsDeleteTime, d.DeleteTime != nil && !d.DeleteTime.IsZero()) }) } } diff --git a/backend/v3/domain/instance_domain.go b/backend/v3/domain/instance_domain.go index 7ca01ae66..9d11be924 100644 --- a/backend/v3/domain/instance_domain.go +++ b/backend/v3/domain/instance_domain.go @@ -51,6 +51,8 @@ type instanceDomainConditions interface { PrimaryKeyCondition(domain string) database.Condition // TypeCondition returns a filter for the type field. TypeCondition(typ DomainType) database.Condition + // IsGeneratedCondition returns a filter for the is_generated field + IsGeneratedCondition(isGenerated bool) database.Condition } type instanceDomainChanges interface { diff --git a/backend/v3/domain/instance_domain_add.go b/backend/v3/domain/instance_domain_add.go new file mode 100644 index 000000000..626112db0 --- /dev/null +++ b/backend/v3/domain/instance_domain_add.go @@ -0,0 +1,123 @@ +package domain + +import ( + "context" + "errors" + "regexp" + "strings" + + "github.com/muhlemmer/gu" + + "github.com/zitadel/zitadel/backend/v3/storage/database" + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/instance" + "github.com/zitadel/zitadel/internal/zerrors" +) + +var allowedDomainCharacters = regexp.MustCompile(`^[a-zA-Z0-9\\.\\-]+$`) + +const maxDomainNameLength = 253 + +type AddInstanceDomainCommand struct { + InstanceID string `json:"instance_id"` + DomainName string `json:"domain_name"` + DType DomainType `json:"domain_type"` +} + +// RequiresTransaction implements [Transactional]. +func (a *AddInstanceDomainCommand) RequiresTransaction() {} + +func NewAddInstanceDomainCommand(instanceID, domainName string, domainType DomainType) *AddInstanceDomainCommand { + return &AddInstanceDomainCommand{ + InstanceID: instanceID, + DomainName: domainName, + DType: domainType, + } +} + +// Events implements [Commander]. +func (a *AddInstanceDomainCommand) Events(ctx context.Context, _ *InvokeOpts) ([]eventstore.Command, error) { + var event eventstore.Command + switch a.DType { + case DomainTypeCustom: + event = instance.NewDomainAddedEvent(ctx, &instance.NewAggregate(a.InstanceID).Aggregate, a.DomainName, false) + case DomainTypeTrusted: + event = instance.NewTrustedDomainAddedEvent(ctx, &instance.NewAggregate(a.InstanceID).Aggregate, a.DomainName) + default: + return nil, nil + } + + return []eventstore.Command{ + event, + }, nil +} + +// Execute implements [Commander]. +func (a *AddInstanceDomainCommand) Execute(ctx context.Context, opts *InvokeOpts) (err error) { + instanceDomainRepo := opts.instanceDomainRepo + + var isPrimary, isGenerated *bool + if a.DType == DomainTypeCustom { + isPrimary, isGenerated = gu.Ptr(false), gu.Ptr(false) + } + + err = instanceDomainRepo.Add(ctx, opts.DB(), &AddInstanceDomain{ + InstanceID: a.InstanceID, + Domain: a.DomainName, + IsPrimary: isPrimary, + IsGenerated: isGenerated, + Type: a.DType, + }) + if err != nil { + return zerrors.ThrowInternal(err, "DOM-uSCVn3", "Errors.Instance.Domain.Add") + } + + return nil +} + +// String implements [Commander]. +func (a *AddInstanceDomainCommand) String() string { + return "AddInstanceDomainCommand" +} + +// Validate implements [Commander]. +func (a *AddInstanceDomainCommand) Validate(ctx context.Context, opts *InvokeOpts) (err error) { + if a.DomainName = strings.TrimSpace(a.DomainName); a.DomainName == "" || len(a.DomainName) > maxDomainNameLength { + return zerrors.ThrowInvalidArgument(nil, "DOM-jieuM8", "Errors.Invalid.Argument") + } + + if a.InstanceID = strings.TrimSpace(a.InstanceID); a.InstanceID == "" { + return zerrors.ThrowInvalidArgument(nil, "DOM-YaUBp5", "Errors.Invalid.Argument") + } + + if !allowedDomainCharacters.MatchString(a.DomainName) { + return zerrors.ThrowInvalidArgument(nil, "DOM-98VcSQ", "Errors.Instance.Domain.InvalidCharacter") + } + + // TODO(IAM-Marco): Do we want to restrict to the instance in context? + if a.InstanceID != authz.GetInstance(ctx).InstanceID() { + return zerrors.ThrowInvalidArgument(nil, "DOM-x01cai", "Errors.Invalid.Argument") + } + + if authZErr := opts.Permissions.CheckInstancePermission(ctx, DomainWritePermission); authZErr != nil { + return zerrors.ThrowPermissionDenied(authZErr, "DOM-c83vPX", "Errors.PermissionDenied") + } + + domainRepo := opts.instanceDomainRepo + _, err = domainRepo.Get(ctx, opts.DB(), database.WithCondition(domainRepo.DomainCondition(database.TextOperationEqual, a.DomainName))) + if err == nil { + return zerrors.ThrowAlreadyExists(nil, "DOM-CvQ8tf", "Errors.Instance.Domain.AlreadyExists") + } + + if !errors.Is(err, &database.NoRowFoundError{}) { + return zerrors.ThrowInternal(err, "DOM-LrTy2z", "Errors.Instance.Domain.Get") + } + + return nil +} + +var ( + _ Commander = (*AddInstanceDomainCommand)(nil) + _ Transactional = (*AddInstanceDomainCommand)(nil) +) diff --git a/backend/v3/domain/instance_domain_add_test.go b/backend/v3/domain/instance_domain_add_test.go new file mode 100644 index 000000000..d81e4576d --- /dev/null +++ b/backend/v3/domain/instance_domain_add_test.go @@ -0,0 +1,316 @@ +package domain_test + +import ( + "errors" + "testing" + + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/zitadel/zitadel/backend/v3/domain" + domainmock "github.com/zitadel/zitadel/backend/v3/domain/mock" + "github.com/zitadel/zitadel/backend/v3/storage/database" + "github.com/zitadel/zitadel/backend/v3/storage/database/dbmock" + noopdb "github.com/zitadel/zitadel/backend/v3/storage/database/dialect/noop" + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/instance" + "github.com/zitadel/zitadel/internal/zerrors" +) + +func TestAddInstanceDomainCommand_Validate(t *testing.T) { + t.Parallel() + getErr := errors.New("get error") + permissionErr := errors.New("permission error") + + tt := []struct { + testName string + instanceDomainRepo func(ctrl *gomock.Controller) domain.InstanceDomainRepository + permissionChecker func(ctrl *gomock.Controller) domain.PermissionChecker + inputInstanceID string + inputDomainName string + expectedError error + }{ + { + testName: "when no name should return invalid argument error", + inputInstanceID: " ", + inputDomainName: " ", + expectedError: zerrors.ThrowInvalidArgument(nil, "DOM-jieuM8", "Errors.Invalid.Argument"), + }, + { + testName: "when name too long should return invalid argument error", + inputInstanceID: " ", + inputDomainName: "domain.is.to.loooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong", + expectedError: zerrors.ThrowInvalidArgument(nil, "DOM-jieuM8", "Errors.Invalid.Argument"), + }, + { + testName: "when no ID should return invalid argument error", + inputInstanceID: " ", + inputDomainName: " domain.name", + expectedError: zerrors.ThrowInvalidArgument(nil, "DOM-YaUBp5", "Errors.Invalid.Argument"), + }, + { + testName: "when domain name contains invalid characters should return invalid argument error", + inputInstanceID: "instance-1 ", + inputDomainName: "?", + expectedError: zerrors.ThrowInvalidArgument(nil, "DOM-98VcSQ", "Errors.Instance.Domain.InvalidCharacter"), + }, + { + testName: "when input instance ID doesn't match instance in context should return invalid argument error", + inputInstanceID: " instance-2", + inputDomainName: "valid.domain", + expectedError: zerrors.ThrowInvalidArgument(nil, "DOM-x01cai", "Errors.Invalid.Argument"), + }, + { + testName: "when user is missing permission should return permission denied", + permissionChecker: func(ctrl *gomock.Controller) domain.PermissionChecker { + permChecker := domainmock.NewMockPermissionChecker(ctrl) + + permChecker.EXPECT(). + CheckInstancePermission(gomock.Any(), domain.DomainWritePermission). + Times(1). + Return(permissionErr) + + return permChecker + }, + inputInstanceID: "instance-1", + inputDomainName: "valid.domain", + expectedError: zerrors.ThrowPermissionDenied(permissionErr, "DOM-c83vPX", "Errors.PermissionDenied"), + }, + { + testName: "when retrieving instance domain fails with generic error should return error", + permissionChecker: func(ctrl *gomock.Controller) domain.PermissionChecker { + permChecker := domainmock.NewMockPermissionChecker(ctrl) + + permChecker.EXPECT(). + CheckInstancePermission(gomock.Any(), domain.DomainWritePermission). + Times(1). + Return(nil) + + return permChecker + }, + instanceDomainRepo: func(ctrl *gomock.Controller) domain.InstanceDomainRepository { + repo := domainmock.NewInstancesDomainRepo(ctrl) + repo.EXPECT(). + Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions( + database.WithCondition( + repo.DomainCondition(database.TextOperationEqual, "valid.domain"), + ), + )). + Times(1). + Return(nil, getErr) + return repo + }, + inputInstanceID: "instance-1", + inputDomainName: "valid.domain", + expectedError: zerrors.ThrowInternal(getErr, "DOM-LrTy2z", "Errors.Instance.Domain.Get"), + }, + { + testName: "when retrieving instance domain succeeds should return already exists error", + permissionChecker: func(ctrl *gomock.Controller) domain.PermissionChecker { + permChecker := domainmock.NewMockPermissionChecker(ctrl) + + permChecker.EXPECT(). + CheckInstancePermission(gomock.Any(), domain.DomainWritePermission). + Times(1). + Return(nil) + + return permChecker + }, + instanceDomainRepo: func(ctrl *gomock.Controller) domain.InstanceDomainRepository { + repo := domainmock.NewInstancesDomainRepo(ctrl) + repo.EXPECT(). + Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions( + database.WithCondition( + repo.DomainCondition(database.TextOperationEqual, "valid.domain"), + ), + )). + Times(1). + Return(&domain.InstanceDomain{}, nil) + return repo + }, + inputInstanceID: "instance-1", + inputDomainName: "valid.domain", + expectedError: zerrors.ThrowAlreadyExists(nil, "DOM-CvQ8tf", "Errors.Instance.Domain.AlreadyExists"), + }, + { + testName: "when retrieving instance domain fails with not found error validation should succeed", + permissionChecker: func(ctrl *gomock.Controller) domain.PermissionChecker { + permChecker := domainmock.NewMockPermissionChecker(ctrl) + + permChecker.EXPECT(). + CheckInstancePermission(gomock.Any(), domain.DomainWritePermission). + Times(1). + Return(nil) + + return permChecker + }, + instanceDomainRepo: func(ctrl *gomock.Controller) domain.InstanceDomainRepository { + repo := domainmock.NewInstancesDomainRepo(ctrl) + repo.EXPECT(). + Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions( + database.WithCondition( + repo.DomainCondition(database.TextOperationEqual, "valid.domain"), + ), + )). + Times(1). + Return(nil, &database.NoRowFoundError{}) + return repo + }, + inputInstanceID: "instance-1", + inputDomainName: "valid.domain", + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + // Given + ctx := authz.NewMockContext("instance-1", "org-1", "") + ctrl := gomock.NewController(t) + cmd := domain.NewAddInstanceDomainCommand(tc.inputInstanceID, tc.inputDomainName, domain.DomainTypeCustom) + + opts := &domain.InvokeOpts{ + Invoker: domain.NewTransactionInvoker(nil), + } + domain.WithQueryExecutor(new(noopdb.Pool))(opts) + if tc.instanceDomainRepo != nil { + domain.WithInstanceDomainRepo(tc.instanceDomainRepo(ctrl))(opts) + } + if tc.permissionChecker != nil { + opts.Permissions = tc.permissionChecker(ctrl) + } + + err := cmd.Validate(ctx, opts) + assert.Equal(t, tc.expectedError, err) + }) + } +} + +func TestAddInstanceDomainCommand_Events(t *testing.T) { + t.Parallel() + + tt := []struct { + testName string + domainType domain.DomainType + expectedAggregateID string + expectedEventType eventstore.Command + }{ + { + testName: "trusted domain", + domainType: domain.DomainTypeTrusted, + expectedAggregateID: "instance-1", + expectedEventType: &instance.TrustedDomainAddedEvent{}, + }, + { + testName: "custom domain", + domainType: domain.DomainTypeCustom, + expectedAggregateID: "instance-1", + expectedEventType: &instance.DomainAddedEvent{}, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + // Given + instanceID := "instance-1" + domainName := "test.domain" + ctx := authz.NewMockContext(instanceID, "org-1", "") + cmd := domain.NewAddInstanceDomainCommand(instanceID, domainName, tc.domainType) + + // When + events, err := cmd.Events(ctx, &domain.InvokeOpts{}) + + // Then + require.NoError(t, err) + require.Len(t, events, 1) + require.NotNil(t, events[0].Aggregate()) + assert.Equal(t, instanceID, events[0].Aggregate().ID) + + require.IsType(t, tc.expectedEventType, events[0]) + + switch asserted := events[0].(type) { + case *instance.DomainAddedEvent: + assert.Equal(t, domainName, asserted.Domain) + assert.False(t, asserted.Generated) + case *instance.TrustedDomainAddedEvent: + assert.Equal(t, domainName, asserted.Domain) + } + }) + } +} + +func TestAddInstanceDomainCommand_Execute(t *testing.T) { + t.Parallel() + addErr := errors.New("add error") + + tt := []struct { + testName string + instanceDomainRepo func(ctrl *gomock.Controller) domain.InstanceDomainRepository + inputDomainType domain.DomainType + expectedError error + }{ + { + testName: "when adding domain succeeds should return nil", + inputDomainType: domain.DomainTypeCustom, + instanceDomainRepo: func(ctrl *gomock.Controller) domain.InstanceDomainRepository { + repo := domainmock.NewInstancesDomainRepo(ctrl) + repo.EXPECT(). + Add(gomock.Any(), gomock.Any(), &domain.AddInstanceDomain{ + InstanceID: "instance-1", + Domain: "valid.domain", + IsPrimary: gu.Ptr(false), + IsGenerated: gu.Ptr(false), + Type: domain.DomainTypeCustom, + }). + Times(1). + Return(nil) + return repo + }, + }, + { + testName: "when adding domain fails should return error", + inputDomainType: domain.DomainTypeTrusted, + instanceDomainRepo: func(ctrl *gomock.Controller) domain.InstanceDomainRepository { + repo := domainmock.NewInstancesDomainRepo(ctrl) + repo.EXPECT(). + Add(gomock.Any(), gomock.Any(), &domain.AddInstanceDomain{ + InstanceID: "instance-1", + Domain: "valid.domain", + Type: domain.DomainTypeTrusted, + }). + Times(1). + Return(addErr) + return repo + }, + expectedError: zerrors.ThrowInternal(addErr, "DOM-uSCVn3", "Errors.Instance.Domain.Add"), + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + // Given + ctx := authz.NewMockContext("instance-1", "org-1", "") + ctrl := gomock.NewController(t) + cmd := domain.NewAddInstanceDomainCommand("instance-1", "valid.domain", tc.inputDomainType) + + opts := &domain.InvokeOpts{ + Invoker: domain.NewTransactionInvoker(nil), + } + domain.WithQueryExecutor(new(noopdb.Pool))(opts) + if tc.instanceDomainRepo != nil { + domain.WithInstanceDomainRepo(tc.instanceDomainRepo(ctrl))(opts) + } + + // When + err := cmd.Execute(ctx, opts) + + // Then + assert.Equal(t, tc.expectedError, err) + }) + } +} diff --git a/backend/v3/domain/instance_domain_list.go b/backend/v3/domain/instance_domain_list.go new file mode 100644 index 000000000..ae0a49cc8 --- /dev/null +++ b/backend/v3/domain/instance_domain_list.go @@ -0,0 +1,134 @@ +package domain + +import ( + "context" + "strings" + + "github.com/zitadel/zitadel/backend/v3/api/object" + "github.com/zitadel/zitadel/backend/v3/storage/database" + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/zerrors" + instance_v2 "github.com/zitadel/zitadel/pkg/grpc/instance/v2" +) + +type ListInstanceDomainsQuery struct { + Request *instance_v2.ListCustomDomainsRequest + + toReturn []*InstanceDomain +} + +var _ Querier[[]*InstanceDomain] = (*ListInstanceDomainsQuery)(nil) + +func NewListInstanceDomainsQuery(inputRequest *instance_v2.ListCustomDomainsRequest) *ListInstanceDomainsQuery { + return &ListInstanceDomainsQuery{ + Request: inputRequest, + } +} + +// Result implements [Querier]. +func (l *ListInstanceDomainsQuery) Result() []*InstanceDomain { + return l.toReturn +} + +// Execute implements [Querier]. +func (l *ListInstanceDomainsQuery) Execute(ctx context.Context, opts *InvokeOpts) (err error) { + domainRepo := opts.instanceDomainRepo + + sorting := l.Sorting(domainRepo) + limit, offset := l.Pagination() + conds, err := l.conditions(domainRepo) + if err != nil { + return err + } + + instances, err := domainRepo.List(ctx, opts.DB(), conds, sorting, limit, offset) + if err != nil { + return zerrors.ThrowInternal(err, "DOM-ubaPNU", "Errors.Instance.Domain.List") + } + + l.toReturn = instances + + return nil +} + +func (l *ListInstanceDomainsQuery) Pagination() (database.QueryOption, database.QueryOption) { + return database.WithLimit(l.Request.GetPagination().GetLimit()), + database.WithOffset(uint32(l.Request.GetPagination().GetOffset())) +} + +func (l *ListInstanceDomainsQuery) conditions(domainRepo InstanceDomainRepository) (database.QueryOption, error) { + conditions := make([]database.Condition, len(l.Request.GetFilters())) + for i, query := range l.Request.GetFilters() { + switch assertedType := query.GetFilter().(type) { + case *instance_v2.CustomDomainFilter_DomainFilter: + method, err := object.TextQueryMethodToTextOperation(assertedType.DomainFilter.GetMethod()) + if err != nil { + return nil, err + } + conditions[i] = domainRepo.DomainCondition( + method, + assertedType.DomainFilter.GetDomain(), + ) + case *instance_v2.CustomDomainFilter_GeneratedFilter: + conditions[i] = domainRepo.IsGeneratedCondition(assertedType.GeneratedFilter) + case *instance_v2.CustomDomainFilter_PrimaryFilter: + conditions[i] = domainRepo.IsPrimaryCondition(assertedType.PrimaryFilter) + default: + return nil, zerrors.ThrowInvalidArgument(NewUnexpectedQueryTypeError(assertedType), "DOM-CjM93P", "List.Query.Invalid") + } + } + + conditions = append(conditions, domainRepo.TypeCondition(DomainTypeCustom)) + return database.WithCondition(database.And(conditions...)), nil +} + +func (l *ListInstanceDomainsQuery) Sorting(domainRepo InstanceDomainRepository) database.QueryOption { + var sortingCol database.Column + + switch l.Request.GetSortingColumn() { + + case instance_v2.DomainFieldName_DOMAIN_FIELD_NAME_CREATION_DATE, instance_v2.DomainFieldName_DOMAIN_FIELD_NAME_UNSPECIFIED: + sortingCol = domainRepo.CreatedAtColumn() + case instance_v2.DomainFieldName_DOMAIN_FIELD_NAME_DOMAIN: + sortingCol = domainRepo.DomainColumn() + case instance_v2.DomainFieldName_DOMAIN_FIELD_NAME_GENERATED: + sortingCol = domainRepo.IsGeneratedColumn() + case instance_v2.DomainFieldName_DOMAIN_FIELD_NAME_PRIMARY: + sortingCol = domainRepo.IsPrimaryColumn() + default: + return func(opts *database.QueryOpts) {} + } + + if l.Request.GetPagination().GetAsc() { + return database.WithOrderByAscending(sortingCol) + } + + return database.WithOrderByDescending(sortingCol) +} + +// String implements [Querier]. +func (l *ListInstanceDomainsQuery) String() string { + return "ListInstanceDomainsQuery" +} + +// Validate implements [Querier]. +func (l *ListInstanceDomainsQuery) Validate(ctx context.Context, opts *InvokeOpts) (err error) { + instanceID := strings.TrimSpace(l.Request.GetInstanceId()) + if instanceID == "" || instanceID == authz.GetInstance(ctx).InstanceID() { + return l.checkDomainPerms(ctx, opts) + } + + // TODO(IAM-Marco): This is wrong, as it should check the permission for the input instance and not the one in context + if authZErr := opts.Permissions.CheckInstancePermission(ctx, InstanceReadPermission); authZErr != nil { + return zerrors.ThrowPermissionDenied(authZErr, "DOM-yN7oCp", "Errors.PermissionDenied") + } + + return l.checkDomainPerms(ctx, opts) +} + +func (l *ListInstanceDomainsQuery) checkDomainPerms(ctx context.Context, opts *InvokeOpts) error { + if authZErr := opts.Permissions.CheckInstancePermission(ctx, DomainReadPermission); authZErr != nil { + return zerrors.ThrowPermissionDenied(authZErr, "DOM-RyCEyr", "Errors.PermissionDenied") + } + return nil +} diff --git a/backend/v3/domain/instance_domain_list_test.go b/backend/v3/domain/instance_domain_list_test.go new file mode 100644 index 000000000..ef0ad1c9f --- /dev/null +++ b/backend/v3/domain/instance_domain_list_test.go @@ -0,0 +1,361 @@ +package domain_test + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" + + "github.com/zitadel/zitadel/backend/v3/domain" + domainmock "github.com/zitadel/zitadel/backend/v3/domain/mock" + "github.com/zitadel/zitadel/backend/v3/storage/database" + "github.com/zitadel/zitadel/backend/v3/storage/database/dbmock" + noopdb "github.com/zitadel/zitadel/backend/v3/storage/database/dialect/noop" + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/zerrors" + filter "github.com/zitadel/zitadel/pkg/grpc/filter/v2" + instance "github.com/zitadel/zitadel/pkg/grpc/instance/v2" + "github.com/zitadel/zitadel/pkg/grpc/object/v2" +) + +func TestListInstanceDomainsQuery_Validate(t *testing.T) { + t.Parallel() + permissionErr := errors.New("permission error") + + tt := []struct { + name string + request *instance.ListCustomDomainsRequest + permissionChecker func(ctrl *gomock.Controller) domain.PermissionChecker + expectedError error + }{ + { + name: "when missing domain read permission should return permission denied (empty instance id)", + request: &instance.ListCustomDomainsRequest{InstanceId: " "}, + permissionChecker: func(ctrl *gomock.Controller) domain.PermissionChecker { + checker := domainmock.NewMockPermissionChecker(ctrl) + checker.EXPECT(). + CheckInstancePermission(gomock.Any(), domain.DomainReadPermission). + Times(1). + Return(permissionErr) + return checker + }, + expectedError: zerrors.ThrowPermissionDenied(permissionErr, "DOM-RyCEyr", "Errors.PermissionDenied"), + }, + { + name: "when valid permissions should return no error (ctx instance same as input)", + request: &instance.ListCustomDomainsRequest{InstanceId: "instance-1"}, + permissionChecker: func(ctrl *gomock.Controller) domain.PermissionChecker { + checker := domainmock.NewMockPermissionChecker(ctrl) + checker.EXPECT(). + CheckInstancePermission(gomock.Any(), domain.DomainReadPermission). + Times(1). + Return(nil) + return checker + }, + }, + { + name: "when input instance doesn't match context should check instance and return error", + request: &instance.ListCustomDomainsRequest{ + InstanceId: "different-instance", + }, + permissionChecker: func(ctrl *gomock.Controller) domain.PermissionChecker { + checker := domainmock.NewMockPermissionChecker(ctrl) + checker.EXPECT(). + CheckInstancePermission(gomock.Any(), domain.InstanceReadPermission). + Times(1). + Return(permissionErr) + return checker + }, + expectedError: zerrors.ThrowPermissionDenied(permissionErr, "DOM-yN7oCp", "Errors.PermissionDenied"), + }, + { + name: "when input instance doesn't match context should check instance and succeed then check domain and fail", + request: &instance.ListCustomDomainsRequest{ + InstanceId: "different-instance", + }, + permissionChecker: func(ctrl *gomock.Controller) domain.PermissionChecker { + checker := domainmock.NewMockPermissionChecker(ctrl) + checker.EXPECT(). + CheckInstancePermission(gomock.Any(), domain.InstanceReadPermission). + Times(1). + Return(nil) + checker.EXPECT(). + CheckInstancePermission(gomock.Any(), domain.DomainReadPermission). + Times(1). + Return(permissionErr) + return checker + }, + expectedError: zerrors.ThrowPermissionDenied(permissionErr, "DOM-RyCEyr", "Errors.PermissionDenied"), + }, + { + name: "when input instance doesn't match context should check instance and succeed then check domain and succeed", + request: &instance.ListCustomDomainsRequest{ + InstanceId: "different-instance", + }, + permissionChecker: func(ctrl *gomock.Controller) domain.PermissionChecker { + checker := domainmock.NewMockPermissionChecker(ctrl) + checker.EXPECT(). + CheckInstancePermission(gomock.Any(), domain.InstanceReadPermission). + Times(1). + Return(nil) + checker.EXPECT(). + CheckInstancePermission(gomock.Any(), domain.DomainReadPermission). + Times(1). + Return(nil) + return checker + }, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + ctx := authz.NewMockContext("instance-1", "org-1", "") + + query := domain.NewListInstanceDomainsQuery(tc.request) + err := query.Validate(ctx, &domain.InvokeOpts{ + Permissions: tc.permissionChecker(ctrl), + }) + + if tc.expectedError != nil { + assert.Error(t, err) + assert.Equal(t, tc.expectedError, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestListInstanceDomainsQuery_Execute(t *testing.T) { + t.Parallel() + listErr := errors.New("list mock error") + + tt := []struct { + name string + request *instance.ListCustomDomainsRequest + repo func(ctrl *gomock.Controller) domain.InstanceDomainRepository + expectedError error + expectedDomains []*domain.InstanceDomain + }{ + { + name: "when parsing conditions fails should return invalid argument error", + request: &instance.ListCustomDomainsRequest{ + Pagination: &filter.PaginationRequest{Limit: 2, Offset: 1, Asc: false}, + Filters: []*instance.CustomDomainFilter{ + {Filter: &instance.CustomDomainFilter_DomainFilter{ + DomainFilter: &instance.DomainFilter{ + Domain: "test.domain", + Method: 99, + }, + }}, + }, + SortingColumn: instance.DomainFieldName_DOMAIN_FIELD_NAME_DOMAIN, + }, + repo: func(ctrl *gomock.Controller) domain.InstanceDomainRepository { + repo := domainmock.NewInstancesDomainRepo(ctrl) + return repo + }, + expectedError: zerrors.ThrowInvalidArgument(nil, "OBJ-iBRBVe", "invalid text query method"), + }, + { + name: "when listing domains fails should return error", + request: &instance.ListCustomDomainsRequest{ + Pagination: &filter.PaginationRequest{Limit: 2, Offset: 1, Asc: false}, + Filters: []*instance.CustomDomainFilter{ + {Filter: &instance.CustomDomainFilter_DomainFilter{ + DomainFilter: &instance.DomainFilter{ + Domain: "test.domain", + Method: object.TextQueryMethod_TEXT_QUERY_METHOD_CONTAINS, + }, + }}, + }, + SortingColumn: instance.DomainFieldName_DOMAIN_FIELD_NAME_DOMAIN, + }, + repo: func(ctrl *gomock.Controller) domain.InstanceDomainRepository { + repo := domainmock.NewInstancesDomainRepo(ctrl) + repo.EXPECT(). + List(gomock.Any(), gomock.Any(), + dbmock.QueryOptions( + database.WithCondition(database.And( + repo.DomainCondition(database.TextOperationContains, "test.domain"), + repo.TypeCondition(domain.DomainTypeCustom), + )), + ), + dbmock.QueryOptions(database.WithOrderBy(database.OrderDirectionDesc, repo.DomainColumn())), + dbmock.QueryOptions(database.WithLimit(2)), + dbmock.QueryOptions(database.WithOffset(1)), + ). + Return(nil, listErr) + return repo + }, + expectedError: zerrors.ThrowInternal(listErr, "DOM-ubaPNU", "Errors.Instance.Domain.List"), + }, + { + name: "when listing domains succeeds should return domains", + request: &instance.ListCustomDomainsRequest{ + Filters: []*instance.CustomDomainFilter{ + {Filter: &instance.CustomDomainFilter_DomainFilter{ + DomainFilter: &instance.DomainFilter{ + Domain: "test.domain", + Method: object.TextQueryMethod_TEXT_QUERY_METHOD_CONTAINS, + }, + }}, + {Filter: &instance.CustomDomainFilter_GeneratedFilter{ + GeneratedFilter: true, + }}, + {Filter: &instance.CustomDomainFilter_PrimaryFilter{ + PrimaryFilter: true, + }}, + }, + SortingColumn: instance.DomainFieldName_DOMAIN_FIELD_NAME_DOMAIN, + }, + repo: func(ctrl *gomock.Controller) domain.InstanceDomainRepository { + repo := domainmock.NewInstancesDomainRepo(ctrl) + domains := []*domain.InstanceDomain{ + {Domain: "test1.domain"}, + {Domain: "test2.domain"}, + } + repo.EXPECT(). + List(gomock.Any(), gomock.Any(), + dbmock.QueryOptions( + database.WithCondition(database.And( + repo.DomainCondition(database.TextOperationContains, "test.domain"), + repo.IsGeneratedCondition(true), + repo.IsPrimaryCondition(true), + repo.TypeCondition(domain.DomainTypeCustom), + )), + ), + dbmock.QueryOptions(database.WithOrderBy(database.OrderDirectionDesc, repo.DomainColumn())), + dbmock.QueryOptions(database.WithLimit(0)), + dbmock.QueryOptions(database.WithOffset(0)), + ). + Return(domains, nil) + return repo + }, + expectedDomains: []*domain.InstanceDomain{ + {Domain: "test1.domain"}, + {Domain: "test2.domain"}, + }, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + + opts := &domain.InvokeOpts{ + Invoker: domain.NewTransactionInvoker(nil), + } + domain.WithQueryExecutor(new(noopdb.Pool))(opts) + + if tc.repo != nil { + instDomain := tc.repo(ctrl) + domain.WithInstanceDomainRepo(instDomain)(opts) + } + query := domain.NewListInstanceDomainsQuery(tc.request) + err := query.Execute(t.Context(), opts) + + if tc.expectedError != nil { + assert.Error(t, err) + assert.Equal(t, tc.expectedError, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tc.expectedDomains, query.Result()) + } + }) + } +} + +func TestListInstanceDomainsQuery_Sorting(t *testing.T) { + t.Parallel() + tt := []struct { + name string + request *instance.ListCustomDomainsRequest + + expectedSortingDirection database.OrderDirection + expectedOrderBy database.Columns + }{ + { + name: "sort by creation date DESC", + request: &instance.ListCustomDomainsRequest{ + SortingColumn: instance.DomainFieldName_DOMAIN_FIELD_NAME_CREATION_DATE, + }, + expectedSortingDirection: database.OrderDirectionDesc, + expectedOrderBy: database.Columns{database.NewColumn("instance_domains", "created_at")}, + }, + { + name: "sort by creation date ASC", + request: &instance.ListCustomDomainsRequest{ + SortingColumn: instance.DomainFieldName_DOMAIN_FIELD_NAME_CREATION_DATE, + Pagination: &filter.PaginationRequest{Asc: true}, + }, + expectedSortingDirection: database.OrderDirectionAsc, + expectedOrderBy: database.Columns{database.NewColumn("instance_domains", "created_at")}, + }, + { + name: "sort by domain DESC", + request: &instance.ListCustomDomainsRequest{ + SortingColumn: instance.DomainFieldName_DOMAIN_FIELD_NAME_DOMAIN, + }, + expectedSortingDirection: database.OrderDirectionDesc, + expectedOrderBy: database.Columns{database.NewColumn("instance_domains", "domain")}, + }, + { + name: "sort by domain ASC", + request: &instance.ListCustomDomainsRequest{ + SortingColumn: instance.DomainFieldName_DOMAIN_FIELD_NAME_DOMAIN, + Pagination: &filter.PaginationRequest{Asc: true}, + }, + expectedSortingDirection: database.OrderDirectionAsc, + expectedOrderBy: database.Columns{database.NewColumn("instance_domains", "domain")}, + }, + { + name: "sort by generated DESC", + request: &instance.ListCustomDomainsRequest{ + SortingColumn: instance.DomainFieldName_DOMAIN_FIELD_NAME_GENERATED, + }, + expectedSortingDirection: database.OrderDirectionDesc, + expectedOrderBy: database.Columns{database.NewColumn("instance_domains", "is_generated")}, + }, + { + name: "sort by primary DESC", + request: &instance.ListCustomDomainsRequest{ + SortingColumn: instance.DomainFieldName_DOMAIN_FIELD_NAME_PRIMARY, + }, + expectedSortingDirection: database.OrderDirectionDesc, + expectedOrderBy: database.Columns{database.NewColumn("instance_domains", "is_primary")}, + }, + { + name: "unspecified field", + request: &instance.ListCustomDomainsRequest{ + SortingColumn: instance.DomainFieldName_DOMAIN_FIELD_NAME_UNSPECIFIED, + }, + expectedSortingDirection: database.OrderDirectionDesc, + expectedOrderBy: database.Columns{database.NewColumn("instance_domains", "created_at")}, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + // Given + mockCtrl := gomock.NewController(t) + mockRepo := domainmock.NewInstancesDomainRepo(mockCtrl) + + cmd := domain.NewListInstanceDomainsQuery(tc.request) + opts := &database.QueryOpts{} + + // Test + queryOpt := cmd.Sorting(mockRepo) + queryOpt(opts) + + // Verify + assert.Equal(t, tc.expectedOrderBy, opts.OrderBy) + assert.Equal(t, tc.expectedSortingDirection, opts.Ordering) + }) + } +} diff --git a/backend/v3/domain/instance_domain_remove.go b/backend/v3/domain/instance_domain_remove.go new file mode 100644 index 000000000..1fe23777f --- /dev/null +++ b/backend/v3/domain/instance_domain_remove.go @@ -0,0 +1,118 @@ +package domain + +import ( + "context" + "errors" + "strings" + "time" + + "github.com/muhlemmer/gu" + + "github.com/zitadel/zitadel/backend/v3/storage/database" + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/instance" + "github.com/zitadel/zitadel/internal/zerrors" +) + +type RemoveInstanceDomainCommand struct { + InstanceID string `json:"instance_id"` + DomainName string `json:"domain_name"` + DType DomainType `json:"domain_type"` + + DeleteTime *time.Time `json:"delete_date"` +} + +// RequiresTransaction implements [Transactional]. +func (r *RemoveInstanceDomainCommand) RequiresTransaction() {} + +func NewRemoveInstanceDomainCommand(instanceID, domainName string, domainType DomainType) *RemoveInstanceDomainCommand { + return &RemoveInstanceDomainCommand{ + InstanceID: instanceID, + DomainName: domainName, + DType: domainType, + } +} + +// Events implements [Commander]. +func (r *RemoveInstanceDomainCommand) Events(ctx context.Context, _ *InvokeOpts) ([]eventstore.Command, error) { + var toReturn eventstore.Command + switch r.DType { + case DomainTypeCustom: + toReturn = instance.NewDomainRemovedEvent(ctx, &instance.NewAggregate(r.InstanceID).Aggregate, r.DomainName) + case DomainTypeTrusted: + toReturn = instance.NewTrustedDomainRemovedEvent(ctx, &instance.NewAggregate(r.InstanceID).Aggregate, r.DomainName) + default: + return nil, nil + } + return []eventstore.Command{toReturn}, nil +} + +// Execute implements [Commander]. +func (r *RemoveInstanceDomainCommand) Execute(ctx context.Context, opts *InvokeOpts) (err error) { + instanceRepo := opts.instanceDomainRepo + + deletedRows, err := instanceRepo.Remove(ctx, opts.DB(), database.And(instanceRepo.InstanceIDCondition(r.InstanceID), instanceRepo.PrimaryKeyCondition(r.DomainName))) + if err != nil { + return zerrors.ThrowInternal(err, "DOM-KH7AuJ", "Errors.Instance.Domain.Remove") + } + + if deletedRows > 1 { + return zerrors.ThrowInternal(nil, "DOM-XSCnJB", "Errors.Instsance.Domain.DeleteMismatch") + } + + if deletedRows < 1 { + return nil + } + + r.DeleteTime = gu.Ptr(time.Now()) + return err +} + +// String implements [Commander]. +func (r *RemoveInstanceDomainCommand) String() string { + return "RemoveInstanceDomainCommand" +} + +// Validate implements [Commander]. +func (r *RemoveInstanceDomainCommand) Validate(ctx context.Context, opts *InvokeOpts) (err error) { + if r.DomainName = strings.TrimSpace(r.DomainName); r.DomainName == "" || len(r.DomainName) > 253 { + return zerrors.ThrowInvalidArgument(nil, "DOM-PLpYix", "Errors.Invalid.Argument") + } + + if r.InstanceID = strings.TrimSpace(r.InstanceID); r.InstanceID == "" { + return zerrors.ThrowInvalidArgument(nil, "DOM-VSsTTf", "Errors.Invalid.Argument") + } + + if !allowedDomainCharacters.MatchString(r.DomainName) { + return zerrors.ThrowInvalidArgument(nil, "DOM-dYD5I7", "Errors.Instance.Domain.InvalidCharacter") + } + + if r.InstanceID != authz.GetInstance(ctx).InstanceID() { + return zerrors.ThrowInvalidArgument(nil, "DOM-83FUdY", "Errors.Invalid.Argument") + } + + if authZErr := opts.Permissions.CheckInstancePermission(ctx, DomainWritePermission); authZErr != nil { + return zerrors.ThrowPermissionDenied(authZErr, "DOM-eroxID", "Errors.PermissionDenied") + } + + domainRepo := opts.instanceDomainRepo + d, err := domainRepo.Get(ctx, opts.DB(), database.WithCondition(domainRepo.DomainCondition(database.TextOperationEqual, r.DomainName))) + if err != nil { + if errors.Is(err, &database.NoRowFoundError{}) { + return nil + } + return zerrors.ThrowInternal(err, "DOM-Zvv1fi", "Errors.Instance.Domain.Get") + } + + if d.Type == DomainTypeCustom && d.IsGenerated != nil && *d.IsGenerated { + return zerrors.ThrowPreconditionFailed(nil, "DOM-cSfCVG", "Errors.Instance.Domain.GeneratedNotRemovable") + } + + return nil +} + +var ( + _ Commander = (*RemoveInstanceDomainCommand)(nil) + _ Transactional = (*RemoveInstanceDomainCommand)(nil) +) diff --git a/backend/v3/domain/instance_domain_remove_test.go b/backend/v3/domain/instance_domain_remove_test.go new file mode 100644 index 000000000..c71a702ef --- /dev/null +++ b/backend/v3/domain/instance_domain_remove_test.go @@ -0,0 +1,434 @@ +package domain_test + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/zitadel/zitadel/backend/v3/domain" + domainmock "github.com/zitadel/zitadel/backend/v3/domain/mock" + "github.com/zitadel/zitadel/backend/v3/storage/database" + "github.com/zitadel/zitadel/backend/v3/storage/database/dbmock" + noopdb "github.com/zitadel/zitadel/backend/v3/storage/database/dialect/noop" + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/instance" + "github.com/zitadel/zitadel/internal/zerrors" +) + +func TestRemoveInstanceDomainCommand_Validate(t *testing.T) { + t.Parallel() + + getErr := errors.New("get error") + noRowFoundErr := &database.NoRowFoundError{} + permissionErr := errors.New("permission error") + + tt := []struct { + testName string + domainRepo func(ctrl *gomock.Controller) domain.InstanceDomainRepository + permissionChecker func(ctrl *gomock.Controller) domain.PermissionChecker + inputInstanceID string + inputDomainName string + expectedError error + }{ + { + testName: "when no instance ID should return invalid argument error", + inputInstanceID: "", + inputDomainName: "test-domain.com", + expectedError: zerrors.ThrowInvalidArgument(nil, "DOM-VSsTTf", "Errors.Invalid.Argument"), + }, + { + testName: "when no domain name should return invalid argument error", + inputInstanceID: "instance-1", + inputDomainName: "", + expectedError: zerrors.ThrowInvalidArgument(nil, "DOM-PLpYix", "Errors.Invalid.Argument"), + }, + { + testName: "when name too long should return invalid argument error", + inputInstanceID: " ", + inputDomainName: "domain.is.to.loooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong", + expectedError: zerrors.ThrowInvalidArgument(nil, "DOM-PLpYix", "Errors.Invalid.Argument"), + }, + { + testName: "when instance ID does not match context should return invalid argument error", + inputInstanceID: "different-instance", + inputDomainName: "test-domain.com", + expectedError: zerrors.ThrowInvalidArgument(nil, "DOM-83FUdY", "Errors.Invalid.Argument"), + }, + { + testName: "when user is missing permission should return permission denied", + permissionChecker: func(ctrl *gomock.Controller) domain.PermissionChecker { + permChecker := domainmock.NewMockPermissionChecker(ctrl) + permChecker.EXPECT(). + CheckInstancePermission(gomock.Any(), domain.DomainWritePermission). + Times(1). + Return(permissionErr) + return permChecker + }, + inputInstanceID: "instance-1", + inputDomainName: "test-domain.com", + expectedError: zerrors.ThrowPermissionDenied(permissionErr, "DOM-eroxID", "Errors.PermissionDenied"), + }, + { + testName: "when domain not found should return no error", + permissionChecker: func(ctrl *gomock.Controller) domain.PermissionChecker { + permChecker := domainmock.NewMockPermissionChecker(ctrl) + permChecker.EXPECT(). + CheckInstancePermission(gomock.Any(), domain.DomainWritePermission). + Times(1). + Return(nil) + return permChecker + }, + domainRepo: func(ctrl *gomock.Controller) domain.InstanceDomainRepository { + repo := domainmock.NewMockInstanceDomainRepository(ctrl) + domainCond := database.NewTextCondition( + database.NewColumn("instance_domains", "domain"), + database.TextOperationEqual, + "test-domain.com") + repo.EXPECT(). + DomainCondition(database.TextOperationEqual, "test-domain.com"). + Times(1). + Return(domainCond) + repo.EXPECT(). + Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions(database.WithCondition(domainCond))). + Times(1). + Return(nil, noRowFoundErr) + return repo + }, + inputInstanceID: "instance-1", + inputDomainName: "test-domain.com", + }, + { + testName: "when get domain fails should return error", + permissionChecker: func(ctrl *gomock.Controller) domain.PermissionChecker { + permChecker := domainmock.NewMockPermissionChecker(ctrl) + permChecker.EXPECT(). + CheckInstancePermission(gomock.Any(), domain.DomainWritePermission). + Times(1). + Return(nil) + return permChecker + }, + domainRepo: func(ctrl *gomock.Controller) domain.InstanceDomainRepository { + repo := domainmock.NewMockInstanceDomainRepository(ctrl) + domainCond := database.NewTextCondition( + database.NewColumn("instance_domains", "domain"), + database.TextOperationEqual, + "test-domain.com") + repo.EXPECT(). + DomainCondition(database.TextOperationEqual, "test-domain.com"). + Times(1). + Return(domainCond) + repo.EXPECT(). + Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions(database.WithCondition(domainCond))). + Times(1). + Return(nil, getErr) + return repo + }, + inputInstanceID: "instance-1", + inputDomainName: "test-domain.com", + expectedError: zerrors.ThrowInternal(getErr, "DOM-Zvv1fi", "Errors.Instance.Domain.Get"), + }, + { + testName: "when domain is generated should return precondition failed error", + permissionChecker: func(ctrl *gomock.Controller) domain.PermissionChecker { + permChecker := domainmock.NewMockPermissionChecker(ctrl) + permChecker.EXPECT(). + CheckInstancePermission(gomock.Any(), domain.DomainWritePermission). + Times(1). + Return(nil) + return permChecker + }, + domainRepo: func(ctrl *gomock.Controller) domain.InstanceDomainRepository { + repo := domainmock.NewMockInstanceDomainRepository(ctrl) + isGenerated := true + domainCond := database.NewTextCondition( + database.NewColumn("instance_domains", "domain"), + database.TextOperationEqual, + "test-domain.com") + repo.EXPECT(). + DomainCondition(database.TextOperationEqual, "test-domain.com"). + Times(1). + Return(domainCond) + repo.EXPECT(). + Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions(database.WithCondition(domainCond))). + Times(1). + Return(&domain.InstanceDomain{IsGenerated: &isGenerated, Type: domain.DomainTypeCustom}, nil) + return repo + }, + inputInstanceID: "instance-1", + inputDomainName: "test-domain.com", + expectedError: zerrors.ThrowPreconditionFailed(nil, "DOM-cSfCVG", "Errors.Instance.Domain.GeneratedNotRemovable"), + }, + { + testName: "when domain exists and is not generated should validate successfully", + permissionChecker: func(ctrl *gomock.Controller) domain.PermissionChecker { + permChecker := domainmock.NewMockPermissionChecker(ctrl) + permChecker.EXPECT(). + CheckInstancePermission(gomock.Any(), domain.DomainWritePermission). + Times(1). + Return(nil) + return permChecker + }, + domainRepo: func(ctrl *gomock.Controller) domain.InstanceDomainRepository { + repo := domainmock.NewMockInstanceDomainRepository(ctrl) + isGenerated := false + domainCond := database.NewTextCondition( + database.NewColumn("instance_domains", "domain"), + database.TextOperationEqual, + "test-domain.com") + repo.EXPECT(). + DomainCondition(database.TextOperationEqual, "test-domain.com"). + Times(1). + Return(domainCond) + repo.EXPECT(). + Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions(database.WithCondition(domainCond))). + Times(1). + Return(&domain.InstanceDomain{IsGenerated: &isGenerated, Type: domain.DomainTypeCustom}, nil) + return repo + }, + inputInstanceID: "instance-1", + inputDomainName: "test-domain.com", + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + // Given + ctx := authz.NewMockContext("instance-1", "", "") + ctrl := gomock.NewController(t) + cmd := domain.NewRemoveInstanceDomainCommand(tc.inputInstanceID, tc.inputDomainName, domain.DomainTypeCustom) + + opts := &domain.InvokeOpts{ + Invoker: domain.NewTransactionInvoker(nil), + } + domain.WithQueryExecutor(new(noopdb.Pool))(opts) + if tc.domainRepo != nil { + domain.WithInstanceDomainRepo(tc.domainRepo(ctrl))(opts) + } + if tc.permissionChecker != nil { + opts.Permissions = tc.permissionChecker(ctrl) + } + + err := cmd.Validate(ctx, opts) + assert.Equal(t, tc.expectedError, err) + }) + } +} + +func TestRemoveInstanceDomainCommand_Execute(t *testing.T) { + t.Parallel() + + removeErr := errors.New("remove error") + + tt := []struct { + testName string + domainRepo func(ctrl *gomock.Controller) domain.InstanceDomainRepository + inputInstanceID string + inputDomainName string + expectedError error + expectsDeleteTime bool + }{ + { + testName: "when domain remove fails should return error", + domainRepo: func(ctrl *gomock.Controller) domain.InstanceDomainRepository { + repo := domainmock.NewMockInstanceDomainRepository(ctrl) + domainCond := database.NewTextCondition( + database.NewColumn("instance_domains", "domain"), + database.TextOperationEqual, + "test-domain.com") + repo.EXPECT(). + PrimaryKeyCondition("test-domain.com"). + Times(1). + Return(domainCond) + instanceIDCond := database.NewTextCondition( + database.NewColumn("instance_domains", "instance_id"), + database.TextOperationEqual, + "instance-1", + ) + repo.EXPECT(). + InstanceIDCondition("instance-1"). + Times(1). + Return(instanceIDCond) + repo.EXPECT(). + Remove(gomock.Any(), gomock.Any(), database.And(instanceIDCond, domainCond)). + Times(1). + Return(int64(0), removeErr) + return repo + }, + inputInstanceID: "instance-1", + inputDomainName: "test-domain.com", + expectedError: zerrors.ThrowInternal(removeErr, "DOM-KH7AuJ", "Errors.Instance.Domain.Remove"), + }, + { + testName: "when domain remove returns 0 rows removed should return no error", + domainRepo: func(ctrl *gomock.Controller) domain.InstanceDomainRepository { + repo := domainmock.NewMockInstanceDomainRepository(ctrl) + domainCond := database.NewTextCondition( + database.NewColumn("instance_domains", "domain"), + database.TextOperationEqual, + "test-domain.com") + repo.EXPECT(). + PrimaryKeyCondition("test-domain.com"). + Times(1). + Return(domainCond) + instanceIDCond := database.NewTextCondition( + database.NewColumn("instance_domains", "instance_id"), + database.TextOperationEqual, + "instance-1", + ) + repo.EXPECT(). + InstanceIDCondition("instance-1"). + Times(1). + Return(instanceIDCond) + repo.EXPECT(). + Remove(gomock.Any(), gomock.Any(), database.And(instanceIDCond, domainCond)). + Times(1). + Return(int64(0), nil) + return repo + }, + inputInstanceID: "instance-1", + inputDomainName: "test-domain.com", + }, + { + testName: "when domain remove returns more than 1 row removed should return internal error", + domainRepo: func(ctrl *gomock.Controller) domain.InstanceDomainRepository { + repo := domainmock.NewMockInstanceDomainRepository(ctrl) + domainCond := database.NewTextCondition( + database.NewColumn("instance_domains", "domain"), + database.TextOperationEqual, + "test-domain.com") + repo.EXPECT(). + PrimaryKeyCondition("test-domain.com"). + Times(1). + Return(domainCond) + instanceIDCond := database.NewTextCondition( + database.NewColumn("instance_domains", "instance_id"), + database.TextOperationEqual, + "instance-1", + ) + repo.EXPECT(). + InstanceIDCondition("instance-1"). + Times(1). + Return(instanceIDCond) + repo.EXPECT(). + Remove(gomock.Any(), gomock.Any(), database.And(instanceIDCond, domainCond)). + Times(1). + Return(int64(2), nil) + return repo + }, + inputInstanceID: "instance-1", + inputDomainName: "test-domain.com", + expectedError: zerrors.ThrowInternal(nil, "DOM-XSCnJB", "Errors.Instsance.Domain.DeleteMismatch"), + }, + { + testName: "when domain remove returns 1 row removed should return no error", + domainRepo: func(ctrl *gomock.Controller) domain.InstanceDomainRepository { + repo := domainmock.NewMockInstanceDomainRepository(ctrl) + domainCond := database.NewTextCondition( + database.NewColumn("instance_domains", "domain"), + database.TextOperationEqual, + "test-domain.com") + repo.EXPECT(). + PrimaryKeyCondition("test-domain.com"). + Times(1). + Return(domainCond) + instanceIDCond := database.NewTextCondition( + database.NewColumn("instance_domains", "instance_id"), + database.TextOperationEqual, + "instance-1", + ) + repo.EXPECT(). + InstanceIDCondition("instance-1"). + Times(1). + Return(instanceIDCond) + repo.EXPECT(). + Remove(gomock.Any(), gomock.Any(), database.And(instanceIDCond, domainCond)). + Times(1). + Return(int64(1), nil) + return repo + }, + inputInstanceID: "instance-1", + inputDomainName: "test-domain.com", + expectsDeleteTime: true, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + // Given + ctx := authz.NewMockContext("instance-1", "", "") + ctrl := gomock.NewController(t) + cmd := domain.NewRemoveInstanceDomainCommand(tc.inputInstanceID, tc.inputDomainName, domain.DomainTypeCustom) + + opts := &domain.InvokeOpts{ + Invoker: domain.NewTransactionInvoker(nil), + } + domain.WithQueryExecutor(new(noopdb.Pool))(opts) + if tc.domainRepo != nil { + domain.WithInstanceDomainRepo(tc.domainRepo(ctrl))(opts) + } + + err := cmd.Execute(ctx, opts) + assert.Equal(t, tc.expectedError, err) + assert.Equal(t, tc.expectsDeleteTime, cmd.DeleteTime != nil && !cmd.DeleteTime.IsZero()) + }) + } +} + +func TestRemoveInstanceDomainCommand_Events(t *testing.T) { + t.Parallel() + + tt := []struct { + testName string + domainType domain.DomainType + expectedAggregateID string + expectedEventType eventstore.Command + }{ + { + testName: "trusted domain", + domainType: domain.DomainTypeTrusted, + expectedAggregateID: "instance-1", + expectedEventType: &instance.TrustedDomainRemovedEvent{}, + }, + { + testName: "custom domain", + domainType: domain.DomainTypeCustom, + expectedAggregateID: "instance-1", + expectedEventType: &instance.DomainRemovedEvent{}, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + // Given + instanceID := "instance-1" + domainName := "test.domain" + ctx := authz.NewMockContext(instanceID, "org-1", "") + cmd := domain.NewRemoveInstanceDomainCommand(instanceID, domainName, tc.domainType) + + // When + events, err := cmd.Events(ctx, &domain.InvokeOpts{}) + + // Then + require.NoError(t, err) + require.Len(t, events, 1) + require.NotNil(t, events[0].Aggregate()) + assert.Equal(t, instanceID, events[0].Aggregate().ID) + + require.IsType(t, tc.expectedEventType, events[0]) + + switch asserted := events[0].(type) { + case *instance.DomainRemovedEvent: + assert.Equal(t, domainName, asserted.Domain) + case *instance.TrustedDomainRemovedEvent: + assert.Equal(t, domainName, asserted.Domain) + } + }) + } +} diff --git a/backend/v3/domain/instance_get.go b/backend/v3/domain/instance_get.go index 3b8bbe1a4..790b85952 100644 --- a/backend/v3/domain/instance_get.go +++ b/backend/v3/domain/instance_get.go @@ -2,6 +2,7 @@ package domain import ( "context" + "errors" "strings" "github.com/zitadel/zitadel/backend/v3/storage/database" @@ -29,7 +30,10 @@ func (g *GetInstanceQuery) Execute(ctx context.Context, opts *InvokeOpts) (err e instance, err := instanceRepo.Get(ctx, opts.DB(), database.WithCondition(instanceRepo.IDCondition(g.ID))) if err != nil { - return err + if errors.Is(err, &database.NoRowFoundError{}) { + return zerrors.ThrowNotFound(err, "DOM-QVrUwc", "Errors.Instance.NotFound") + } + return zerrors.ThrowInternal(err, "DOM-lvsRce", "Errors.Instance.Get") } g.returnedInstance = instance @@ -45,14 +49,14 @@ func (g *GetInstanceQuery) String() string { func (g *GetInstanceQuery) Validate(ctx context.Context, opts *InvokeOpts) (err error) { g.ID = strings.TrimSpace(g.ID) if g.ID == "" { - return zerrors.ThrowInvalidArgument(nil, "DOM-32a0o2", "invalid instance ID") + return zerrors.ThrowInvalidArgument(nil, "DOM-32a0o2", "Errors.Instance.ID") } if g.ID != authz.GetInstance(ctx).InstanceID() { return zerrors.ThrowPermissionDenied(nil, "DOM-n0SvVB", "input instance ID doesn't match context instance") } if authZErr := opts.Permissions.CheckInstancePermission(ctx, InstanceReadPermission); authZErr != nil { - return zerrors.ThrowPermissionDenied(authZErr, "DOM-Uq6b00", "permission denied") + return zerrors.ThrowPermissionDenied(authZErr, "DOM-Uq6b00", "Errors.PermissionDenied") } return nil diff --git a/backend/v3/domain/instance_get_test.go b/backend/v3/domain/instance_get_test.go index 4d70cf445..84501279a 100644 --- a/backend/v3/domain/instance_get_test.go +++ b/backend/v3/domain/instance_get_test.go @@ -34,13 +34,13 @@ func TestGetInstanceCommand_Validate(t *testing.T) { name: "empty instance id, error", inputInstanceID: "", ctx: context.Background(), - expectedError: zerrors.ThrowInvalidArgument(nil, "DOM-32a0o2", "invalid instance ID"), + expectedError: zerrors.ThrowInvalidArgument(nil, "DOM-32a0o2", "Errors.Instance.ID"), }, { name: "whitespace instance id, error", inputInstanceID: " ", ctx: context.Background(), - expectedError: zerrors.ThrowInvalidArgument(nil, "DOM-32a0o2", "invalid instance ID"), + expectedError: zerrors.ThrowInvalidArgument(nil, "DOM-32a0o2", "Errors.Instance.ID"), }, { name: "instance id mismatch, error", @@ -62,7 +62,7 @@ func TestGetInstanceCommand_Validate(t *testing.T) { }, inputInstanceID: "instance1", ctx: authz.NewMockContext("instance1", "", ""), - expectedError: zerrors.ThrowPermissionDenied(permissionErr, "DOM-Uq6b00", "permission denied"), + expectedError: zerrors.ThrowPermissionDenied(permissionErr, "DOM-Uq6b00", "Errors.PermissionDenied"), }, { name: "valid instance id, success", @@ -107,6 +107,7 @@ func TestGetInstanceCommand_Execute(t *testing.T) { ctx := authz.NewMockContext("inst-1", "org-1", gofakeit.UUID()) getErr := errors.New("get error") + notFoundErr := database.NewNoRowFoundError(nil) tt := []struct { testName string @@ -141,7 +142,32 @@ func TestGetInstanceCommand_Execute(t *testing.T) { return instanceRepo }, inputInstanceID: "instance-1", - expectedError: getErr, + expectedError: zerrors.ThrowInternal(getErr, "DOM-lvsRce", "Errors.Instance.Get"), + }, + { + testName: "when instance is not found should return not found error", + instanceRepo: func(ctrl *gomock.Controller) domain.InstanceRepository { + instanceRepo := domainmock.NewInstanceRepo(ctrl) + + instanceRepo.EXPECT(). + LoadDomains(). + Times(1). + Return(instanceRepo) + + instanceRepo.EXPECT(). + Get( + gomock.Any(), + gomock.Any(), + dbmock.QueryOptions(database.WithCondition( + instanceRepo.IDCondition("instance-1"), + )), + ). + Times(1). + Return(nil, notFoundErr) + return instanceRepo + }, + inputInstanceID: "instance-1", + expectedError: zerrors.ThrowNotFound(notFoundErr, "DOM-QVrUwc", "Errors.Instance.NotFound"), }, { testName: "when retrieving instance succeeds should set instance on struct and return no error", diff --git a/backend/v3/domain/instance_list.go b/backend/v3/domain/instance_list.go index 7580f6428..f887b275b 100644 --- a/backend/v3/domain/instance_list.go +++ b/backend/v3/domain/instance_list.go @@ -5,11 +5,11 @@ import ( "github.com/zitadel/zitadel/backend/v3/storage/database" "github.com/zitadel/zitadel/internal/zerrors" - instance "github.com/zitadel/zitadel/pkg/grpc/instance/v2beta" + instance_v2 "github.com/zitadel/zitadel/pkg/grpc/instance/v2" ) type ListInstancesQuery struct { - Request *instance.ListInstancesRequest + Request *instance_v2.ListInstancesRequest toReturn []*Instance } @@ -21,7 +21,7 @@ func (l *ListInstancesQuery) Result() []*Instance { var _ Querier[[]*Instance] = (*ListInstancesQuery)(nil) -func NewListInstancesCommand(inputRequest *instance.ListInstancesRequest) *ListInstancesQuery { +func NewListInstancesCommand(inputRequest *instance_v2.ListInstancesRequest) *ListInstancesQuery { return &ListInstancesQuery{ Request: inputRequest, } @@ -41,7 +41,7 @@ func (l *ListInstancesQuery) Execute(ctx context.Context, opts *InvokeOpts) (err instances, err := instanceRepo.List(ctx, opts.DB(), conds, sorting, limit, offset) if err != nil { - return err + return zerrors.ThrowInternal(err, "DOM-AIRPxN", "Errors.Instance.List") } l.toReturn = instances @@ -55,20 +55,20 @@ func (l *ListInstancesQuery) Pagination() (database.QueryOption, database.QueryO } func (l *ListInstancesQuery) conditions(instanceRepo InstanceRepository, domainRepo InstanceDomainRepository) (database.QueryOption, error) { - conditions := make([]database.Condition, len(l.Request.GetQueries())) - for i, query := range l.Request.GetQueries() { - switch assertedType := query.GetQuery().(type) { - case *instance.Query_DomainQuery: - domainConditions := make([]database.Condition, len(assertedType.DomainQuery.GetDomains())) - for j, domain := range assertedType.DomainQuery.GetDomains() { + conditions := make([]database.Condition, len(l.Request.GetFilters())) + for i, query := range l.Request.GetFilters() { + switch assertedType := query.GetFilter().(type) { + case *instance_v2.Filter_CustomDomainsFilter: + domainConditions := make([]database.Condition, len(assertedType.CustomDomainsFilter.GetDomains())) + for j, domain := range assertedType.CustomDomainsFilter.GetDomains() { domainConditions[j] = domainRepo.DomainCondition(database.TextOperationEqual, domain) } conditions[i] = instanceRepo.ExistsDomain( database.Or(domainConditions...), ) - case *instance.Query_IdQuery: - idConditions := make([]database.Condition, len(assertedType.IdQuery.GetIds())) - for j, id := range assertedType.IdQuery.GetIds() { + case *instance_v2.Filter_InIdsFilter: + idConditions := make([]database.Condition, len(assertedType.InIdsFilter.GetIds())) + for j, id := range assertedType.InIdsFilter.GetIds() { idConditions[j] = instanceRepo.IDCondition(id) } conditions[i] = database.Or(idConditions...) @@ -84,13 +84,13 @@ func (l *ListInstancesQuery) Sorting(instanceRepo InstanceRepository) database.Q var sortingCol database.Column switch l.Request.GetSortingColumn() { - case instance.FieldName_FIELD_NAME_CREATION_DATE: + case instance_v2.FieldName_FIELD_NAME_CREATION_DATE: sortingCol = instanceRepo.CreatedAtColumn() - case instance.FieldName_FIELD_NAME_ID: + case instance_v2.FieldName_FIELD_NAME_ID: sortingCol = instanceRepo.IDColumn() - case instance.FieldName_FIELD_NAME_NAME: + case instance_v2.FieldName_FIELD_NAME_NAME: sortingCol = instanceRepo.NameColumn() - case instance.FieldName_FIELD_NAME_UNSPECIFIED: + case instance_v2.FieldName_FIELD_NAME_UNSPECIFIED: fallthrough default: return func(opts *database.QueryOpts) {} @@ -115,7 +115,7 @@ func (l *ListInstancesQuery) Validate(ctx context.Context, opts *InvokeOpts) (er // the instance in context. In here, instead we should probably loop through all retrieved // instances and check their permissions. if authZErr := opts.Permissions.CheckInstancePermission(ctx, InstanceReadPermission); authZErr != nil { - return zerrors.ThrowPermissionDenied(authZErr, "DOM-cuT6Ws", "permission denied") + return zerrors.ThrowPermissionDenied(authZErr, "DOM-cuT6Ws", "Errors.PermissionDenied") } return nil diff --git a/backend/v3/domain/instance_list_test.go b/backend/v3/domain/instance_list_test.go index 1cf43fc98..59bd05be0 100644 --- a/backend/v3/domain/instance_list_test.go +++ b/backend/v3/domain/instance_list_test.go @@ -15,8 +15,8 @@ import ( noopdb "github.com/zitadel/zitadel/backend/v3/storage/database/dialect/noop" "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/zerrors" - filter "github.com/zitadel/zitadel/pkg/grpc/filter/v2beta" - instance "github.com/zitadel/zitadel/pkg/grpc/instance/v2beta" + filter "github.com/zitadel/zitadel/pkg/grpc/filter/v2" + instance "github.com/zitadel/zitadel/pkg/grpc/instance/v2" ) func TestListInstancesCommand_Sorting(t *testing.T) { @@ -31,7 +31,7 @@ func TestListInstancesCommand_Sorting(t *testing.T) { { name: "sort by creation date DESC", request: &instance.ListInstancesRequest{ - SortingColumn: instance.FieldName_FIELD_NAME_CREATION_DATE.Enum(), + SortingColumn: instance.FieldName_FIELD_NAME_CREATION_DATE, }, expectedSortingDirection: database.OrderDirectionDesc, @@ -40,7 +40,7 @@ func TestListInstancesCommand_Sorting(t *testing.T) { { name: "sort by creation date ASC", request: &instance.ListInstancesRequest{ - SortingColumn: instance.FieldName_FIELD_NAME_CREATION_DATE.Enum(), + SortingColumn: instance.FieldName_FIELD_NAME_CREATION_DATE, Pagination: &filter.PaginationRequest{Asc: true}, }, @@ -50,7 +50,7 @@ func TestListInstancesCommand_Sorting(t *testing.T) { { name: "sort by id DESC", request: &instance.ListInstancesRequest{ - SortingColumn: instance.FieldName_FIELD_NAME_ID.Enum(), + SortingColumn: instance.FieldName_FIELD_NAME_ID, }, expectedSortingDirection: database.OrderDirectionDesc, expectedOrderBy: database.Columns{database.NewColumn("instances", "id")}, @@ -58,7 +58,7 @@ func TestListInstancesCommand_Sorting(t *testing.T) { { name: "sort by id ASC", request: &instance.ListInstancesRequest{ - SortingColumn: instance.FieldName_FIELD_NAME_ID.Enum(), + SortingColumn: instance.FieldName_FIELD_NAME_ID, Pagination: &filter.PaginationRequest{Asc: true}, }, expectedSortingDirection: database.OrderDirectionAsc, @@ -67,7 +67,7 @@ func TestListInstancesCommand_Sorting(t *testing.T) { { name: "unspecified field", request: &instance.ListInstancesRequest{ - SortingColumn: instance.FieldName_FIELD_NAME_UNSPECIFIED.Enum(), + SortingColumn: instance.FieldName_FIELD_NAME_UNSPECIFIED, }, expectedSortingDirection: database.OrderDirectionAsc, }, @@ -151,17 +151,17 @@ func TestListInstancesCommand_Execute(t *testing.T) { }, inputRequest: &instance.ListInstancesRequest{ Pagination: &filter.PaginationRequest{Limit: 2, Offset: 1, Asc: false}, - SortingColumn: instance.FieldName_FIELD_NAME_NAME.Enum(), - Queries: []*instance.Query{ - {Query: &instance.Query_IdQuery{IdQuery: &instance.IdsQuery{ + SortingColumn: instance.FieldName_FIELD_NAME_NAME, + Filters: []*instance.Filter{ + {Filter: &instance.Filter_InIdsFilter{InIdsFilter: &filter.InIDsFilter{ Ids: []string{"instance-1", "instance-2"}, }}}, - {Query: &instance.Query_DomainQuery{DomainQuery: &instance.DomainsQuery{ + {Filter: &instance.Filter_CustomDomainsFilter{CustomDomainsFilter: &instance.CustomDomainsFilter{ Domains: []string{"domain1.example.com", "domain2.example.com"}, }}}, }, }, - expectedError: listErr, + expectedError: zerrors.ThrowInternal(listErr, "DOM-AIRPxN", "Errors.Instance.List"), }, { testName: "when listing instances succeeds should save into result and return nil", @@ -216,12 +216,12 @@ func TestListInstancesCommand_Execute(t *testing.T) { }, inputRequest: &instance.ListInstancesRequest{ Pagination: &filter.PaginationRequest{Limit: 2, Offset: 1, Asc: false}, - SortingColumn: instance.FieldName_FIELD_NAME_NAME.Enum(), - Queries: []*instance.Query{ - {Query: &instance.Query_IdQuery{IdQuery: &instance.IdsQuery{ + SortingColumn: instance.FieldName_FIELD_NAME_NAME, + Filters: []*instance.Filter{ + {Filter: &instance.Filter_InIdsFilter{InIdsFilter: &filter.InIDsFilter{ Ids: []string{"instance-1", "instance-2"}, }}}, - {Query: &instance.Query_DomainQuery{DomainQuery: &instance.DomainsQuery{ + {Filter: &instance.Filter_CustomDomainsFilter{CustomDomainsFilter: &instance.CustomDomainsFilter{ Domains: []string{"domain1.example.com", "domain2.example.com"}, }}}, }, @@ -288,7 +288,7 @@ func TestListInstancesCommand_Validate(t *testing.T) { return permChecker }, - expectedError: zerrors.ThrowPermissionDenied(permissionErr, "DOM-cuT6Ws", "permission denied"), + expectedError: zerrors.ThrowPermissionDenied(permissionErr, "DOM-cuT6Ws", "Errors.PermissionDenied"), }, { name: "when valid permission should return no error", diff --git a/backend/v3/domain/instance_trusted_domain_list.go b/backend/v3/domain/instance_trusted_domain_list.go new file mode 100644 index 000000000..7f971d0b0 --- /dev/null +++ b/backend/v3/domain/instance_trusted_domain_list.go @@ -0,0 +1,133 @@ +package domain + +import ( + "context" + "strings" + + "github.com/zitadel/zitadel/backend/v3/api/object" + "github.com/zitadel/zitadel/backend/v3/storage/database" + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/zerrors" + instance_v2 "github.com/zitadel/zitadel/pkg/grpc/instance/v2" +) + +type ListInstanceTrustedDomainsQuery struct { + Request *instance_v2.ListTrustedDomainsRequest + + toReturn []*InstanceDomain +} + +var _ Querier[[]*InstanceDomain] = (*ListInstanceTrustedDomainsQuery)(nil) + +func NewListInstanceTrustedDomainsQuery(inputRequest *instance_v2.ListTrustedDomainsRequest) *ListInstanceTrustedDomainsQuery { + return &ListInstanceTrustedDomainsQuery{ + Request: inputRequest, + } +} + +// Result implements [Querier]. +func (l *ListInstanceTrustedDomainsQuery) Result() []*InstanceDomain { + return l.toReturn +} + +// Execute implements [Querier]. +func (l *ListInstanceTrustedDomainsQuery) Execute(ctx context.Context, opts *InvokeOpts) (err error) { + domainRepo := opts.instanceDomainRepo + + sorting := l.Sorting(domainRepo) + limit, offset := l.Pagination() + conds, err := l.conditions(domainRepo) + if err != nil { + return err + } + + instances, err := domainRepo.List(ctx, opts.DB(), conds, sorting, limit, offset) + if err != nil { + return zerrors.ThrowInternal(err, "DOM-8eoWqY", "Errors.Instance.TrustedDomain.List") + } + + l.toReturn = instances + + return nil +} + +func (l *ListInstanceTrustedDomainsQuery) Pagination() (database.QueryOption, database.QueryOption) { + return database.WithLimit(l.Request.GetPagination().GetLimit()), + database.WithOffset(uint32(l.Request.GetPagination().GetOffset())) +} + +func (l *ListInstanceTrustedDomainsQuery) conditions(domainRepo InstanceDomainRepository) (database.QueryOption, error) { + conditions := make([]database.Condition, len(l.Request.GetFilters())) + for i, query := range l.Request.GetFilters() { + switch assertedType := query.GetFilter().(type) { + + case *instance_v2.TrustedDomainFilter_DomainFilter: + method, err := object.TextQueryMethodToTextOperation(assertedType.DomainFilter.GetMethod()) + if err != nil { + return nil, err + } + conditions[i] = domainRepo.DomainCondition( + method, + assertedType.DomainFilter.GetDomain(), + ) + + default: + return nil, zerrors.ThrowInvalidArgument(NewUnexpectedQueryTypeError(assertedType), "DOM-qMUBMr", "List.Query.Invalid") + } + } + + conditions = append(conditions, domainRepo.TypeCondition(DomainTypeTrusted)) + return database.WithCondition(database.And(conditions...)), nil +} + +func (l *ListInstanceTrustedDomainsQuery) Sorting(domainRepo InstanceDomainRepository) database.QueryOption { + var sortingCol database.Column + + switch l.Request.GetSortingColumn() { + case instance_v2.TrustedDomainFieldName_TRUSTED_DOMAIN_FIELD_NAME_CREATION_DATE: + sortingCol = domainRepo.CreatedAtColumn() + + case instance_v2.TrustedDomainFieldName_TRUSTED_DOMAIN_FIELD_NAME_DOMAIN: + sortingCol = domainRepo.DomainColumn() + case instance_v2.TrustedDomainFieldName_TRUSTED_DOMAIN_FIELD_NAME_UNSPECIFIED: + fallthrough + default: + return func(opts *database.QueryOpts) {} + } + + orderDirection := database.OrderDirectionDesc + if l.Request.GetPagination().GetAsc() { + orderDirection = database.OrderDirectionAsc + } + + return database.WithOrderBy(orderDirection, sortingCol) +} + +// String implements [Querier]. +func (l *ListInstanceTrustedDomainsQuery) String() string { + return "ListInstanceTrustedDomainsQuery" +} + +// Validate implements [Querier]. +func (l *ListInstanceTrustedDomainsQuery) Validate(ctx context.Context, opts *InvokeOpts) (err error) { + // TODO(IAM-Marco): These validations are copied from custom domains. + // The eventstore side has no validation in place. To decide if we want to keep this or remove it + instanceID := strings.TrimSpace(l.Request.GetInstanceId()) + if instanceID == "" || instanceID == authz.GetInstance(ctx).InstanceID() { + return l.checkDomainPerms(ctx, opts) + } + + // TODO(IAM-Marco): This is wrong, as it should check the permission for the input instance and not the one in context + if authZErr := opts.Permissions.CheckInstancePermission(ctx, InstanceReadPermission); authZErr != nil { + return zerrors.ThrowPermissionDenied(authZErr, "DOM-nx0QKI", "Errors.PermissionDenied") + } + + return l.checkDomainPerms(ctx, opts) +} + +func (l *ListInstanceTrustedDomainsQuery) checkDomainPerms(ctx context.Context, opts *InvokeOpts) error { + if authZErr := opts.Permissions.CheckInstancePermission(ctx, DomainReadPermission); authZErr != nil { + return zerrors.ThrowPermissionDenied(authZErr, "DOM-RyCEyr", "Errors.PermissionDenied") + } + return nil +} diff --git a/backend/v3/domain/instance_trusted_domain_list_test.go b/backend/v3/domain/instance_trusted_domain_list_test.go new file mode 100644 index 000000000..9191b01f1 --- /dev/null +++ b/backend/v3/domain/instance_trusted_domain_list_test.go @@ -0,0 +1,336 @@ +package domain_test + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" + + "github.com/zitadel/zitadel/backend/v3/domain" + domainmock "github.com/zitadel/zitadel/backend/v3/domain/mock" + "github.com/zitadel/zitadel/backend/v3/storage/database" + "github.com/zitadel/zitadel/backend/v3/storage/database/dbmock" + noopdb "github.com/zitadel/zitadel/backend/v3/storage/database/dialect/noop" + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/zerrors" + filter "github.com/zitadel/zitadel/pkg/grpc/filter/v2" + instance "github.com/zitadel/zitadel/pkg/grpc/instance/v2" + "github.com/zitadel/zitadel/pkg/grpc/object/v2" +) + +func TestListInstanceTrustedDomainsQuery_Validate(t *testing.T) { + t.Parallel() + permissionErr := errors.New("permission error") + + tt := []struct { + name string + request *instance.ListTrustedDomainsRequest + permissionChecker func(ctrl *gomock.Controller) domain.PermissionChecker + expectedError error + }{ + { + name: "when missing domain read permission should return permission denied (empty instance id)", + request: &instance.ListTrustedDomainsRequest{InstanceId: " "}, + permissionChecker: func(ctrl *gomock.Controller) domain.PermissionChecker { + checker := domainmock.NewMockPermissionChecker(ctrl) + checker.EXPECT(). + CheckInstancePermission(gomock.Any(), domain.DomainReadPermission). + Times(1). + Return(permissionErr) + return checker + }, + expectedError: zerrors.ThrowPermissionDenied(permissionErr, "DOM-RyCEyr", "Errors.PermissionDenied"), + }, + { + name: "when valid permissions should return no error (ctx instance same as input)", + request: &instance.ListTrustedDomainsRequest{InstanceId: "instance-1"}, + permissionChecker: func(ctrl *gomock.Controller) domain.PermissionChecker { + checker := domainmock.NewMockPermissionChecker(ctrl) + checker.EXPECT(). + CheckInstancePermission(gomock.Any(), domain.DomainReadPermission). + Times(1). + Return(nil) + return checker + }, + }, + { + name: "when input instance doesn't match context should check instance and return error", + request: &instance.ListTrustedDomainsRequest{ + InstanceId: "different-instance", + }, + permissionChecker: func(ctrl *gomock.Controller) domain.PermissionChecker { + checker := domainmock.NewMockPermissionChecker(ctrl) + checker.EXPECT(). + CheckInstancePermission(gomock.Any(), domain.InstanceReadPermission). + Times(1). + Return(permissionErr) + return checker + }, + expectedError: zerrors.ThrowPermissionDenied(permissionErr, "DOM-nx0QKI", "Errors.PermissionDenied"), + }, + { + name: "when input instance doesn't match context should check instance and succeed then check domain and fail", + request: &instance.ListTrustedDomainsRequest{ + InstanceId: "different-instance", + }, + permissionChecker: func(ctrl *gomock.Controller) domain.PermissionChecker { + checker := domainmock.NewMockPermissionChecker(ctrl) + checker.EXPECT(). + CheckInstancePermission(gomock.Any(), domain.InstanceReadPermission). + Times(1). + Return(nil) + checker.EXPECT(). + CheckInstancePermission(gomock.Any(), domain.DomainReadPermission). + Times(1). + Return(permissionErr) + return checker + }, + expectedError: zerrors.ThrowPermissionDenied(permissionErr, "DOM-RyCEyr", "Errors.PermissionDenied"), + }, + { + name: "when input instance doesn't match context should check instance and succeed then check domain and succeed", + request: &instance.ListTrustedDomainsRequest{ + InstanceId: "different-instance", + }, + permissionChecker: func(ctrl *gomock.Controller) domain.PermissionChecker { + checker := domainmock.NewMockPermissionChecker(ctrl) + checker.EXPECT(). + CheckInstancePermission(gomock.Any(), domain.InstanceReadPermission). + Times(1). + Return(nil) + checker.EXPECT(). + CheckInstancePermission(gomock.Any(), domain.DomainReadPermission). + Times(1). + Return(nil) + return checker + }, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + ctx := authz.NewMockContext("instance-1", "org-1", "") + + query := domain.NewListInstanceTrustedDomainsQuery(tc.request) + err := query.Validate(ctx, &domain.InvokeOpts{ + Permissions: tc.permissionChecker(ctrl), + }) + + if tc.expectedError != nil { + assert.Error(t, err) + assert.Equal(t, tc.expectedError, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestListInstanceTrustedDomainsQuery_Execute(t *testing.T) { + t.Parallel() + listErr := errors.New("list mock error") + + tt := []struct { + name string + request *instance.ListTrustedDomainsRequest + repo func(ctrl *gomock.Controller) domain.InstanceDomainRepository + expectedError error + expectedDomains []*domain.InstanceDomain + }{ + { + name: "when parsing conditions fails should return invalid argument error", + request: &instance.ListTrustedDomainsRequest{ + Pagination: &filter.PaginationRequest{Limit: 2, Offset: 1, Asc: false}, + Filters: []*instance.TrustedDomainFilter{ + {Filter: &instance.TrustedDomainFilter_DomainFilter{ + DomainFilter: &instance.DomainFilter{ + Domain: "test.domain", + Method: 99, + }, + }}, + }, + SortingColumn: instance.TrustedDomainFieldName_TRUSTED_DOMAIN_FIELD_NAME_DOMAIN, + }, + repo: func(ctrl *gomock.Controller) domain.InstanceDomainRepository { + repo := domainmock.NewInstancesDomainRepo(ctrl) + return repo + }, + expectedError: zerrors.ThrowInvalidArgument(nil, "OBJ-iBRBVe", "invalid text query method"), + }, + { + name: "when listing domains fails should return error", + request: &instance.ListTrustedDomainsRequest{ + Pagination: &filter.PaginationRequest{Limit: 2, Offset: 1, Asc: false}, + Filters: []*instance.TrustedDomainFilter{ + {Filter: &instance.TrustedDomainFilter_DomainFilter{ + DomainFilter: &instance.DomainFilter{ + Domain: "test.domain", + Method: object.TextQueryMethod_TEXT_QUERY_METHOD_CONTAINS, + }, + }}, + }, + SortingColumn: instance.TrustedDomainFieldName_TRUSTED_DOMAIN_FIELD_NAME_DOMAIN, + }, + repo: func(ctrl *gomock.Controller) domain.InstanceDomainRepository { + repo := domainmock.NewInstancesDomainRepo(ctrl) + repo.EXPECT(). + List(gomock.Any(), gomock.Any(), + dbmock.QueryOptions( + database.WithCondition(database.And( + repo.DomainCondition(database.TextOperationContains, "test.domain"), + repo.TypeCondition(domain.DomainTypeTrusted), + )), + ), + dbmock.QueryOptions(database.WithOrderBy(database.OrderDirectionDesc, repo.DomainColumn())), + dbmock.QueryOptions(database.WithLimit(2)), + dbmock.QueryOptions(database.WithOffset(1)), + ). + Return(nil, listErr) + return repo + }, + expectedError: zerrors.ThrowInternal(listErr, "DOM-8eoWqY", "Errors.Instance.TrustedDomain.List"), + }, + { + name: "when listing domains succeeds should return domains", + request: &instance.ListTrustedDomainsRequest{ + Filters: []*instance.TrustedDomainFilter{ + {Filter: &instance.TrustedDomainFilter_DomainFilter{ + DomainFilter: &instance.DomainFilter{ + Domain: "test.domain", + Method: object.TextQueryMethod_TEXT_QUERY_METHOD_CONTAINS, + }, + }}, + }, + SortingColumn: instance.TrustedDomainFieldName_TRUSTED_DOMAIN_FIELD_NAME_DOMAIN, + }, + repo: func(ctrl *gomock.Controller) domain.InstanceDomainRepository { + repo := domainmock.NewInstancesDomainRepo(ctrl) + domains := []*domain.InstanceDomain{ + {Domain: "test1.domain"}, + {Domain: "test2.domain"}, + } + repo.EXPECT(). + List(gomock.Any(), gomock.Any(), + dbmock.QueryOptions( + database.WithCondition(database.And( + repo.DomainCondition(database.TextOperationContains, "test.domain"), + repo.TypeCondition(domain.DomainTypeTrusted), + )), + ), + dbmock.QueryOptions(database.WithOrderBy(database.OrderDirectionDesc, repo.DomainColumn())), + dbmock.QueryOptions(database.WithLimit(0)), + dbmock.QueryOptions(database.WithOffset(0)), + ). + Return(domains, nil) + return repo + }, + expectedDomains: []*domain.InstanceDomain{ + {Domain: "test1.domain"}, + {Domain: "test2.domain"}, + }, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + + opts := &domain.InvokeOpts{ + Invoker: domain.NewTransactionInvoker(nil), + } + domain.WithQueryExecutor(new(noopdb.Pool))(opts) + + if tc.repo != nil { + instDomain := tc.repo(ctrl) + domain.WithInstanceDomainRepo(instDomain)(opts) + } + query := domain.NewListInstanceTrustedDomainsQuery(tc.request) + err := query.Execute(t.Context(), opts) + + if tc.expectedError != nil { + assert.Error(t, err) + assert.Equal(t, tc.expectedError, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tc.expectedDomains, query.Result()) + } + }) + } +} + +func TestListInstanceTrustedDomainsQuery_Sorting(t *testing.T) { + t.Parallel() + tt := []struct { + name string + request *instance.ListTrustedDomainsRequest + + expectedSortingDirection database.OrderDirection + expectedOrderBy database.Columns + }{ + { + name: "sort by creation date DESC", + request: &instance.ListTrustedDomainsRequest{ + SortingColumn: instance.TrustedDomainFieldName_TRUSTED_DOMAIN_FIELD_NAME_CREATION_DATE, + }, + expectedSortingDirection: database.OrderDirectionDesc, + expectedOrderBy: database.Columns{database.NewColumn("instance_domains", "created_at")}, + }, + { + name: "sort by creation date ASC", + request: &instance.ListTrustedDomainsRequest{ + SortingColumn: instance.TrustedDomainFieldName_TRUSTED_DOMAIN_FIELD_NAME_CREATION_DATE, + Pagination: &filter.PaginationRequest{Asc: true}, + }, + expectedSortingDirection: database.OrderDirectionAsc, + expectedOrderBy: database.Columns{database.NewColumn("instance_domains", "created_at")}, + }, + { + name: "sort by domain DESC", + request: &instance.ListTrustedDomainsRequest{ + SortingColumn: instance.TrustedDomainFieldName_TRUSTED_DOMAIN_FIELD_NAME_DOMAIN, + }, + expectedSortingDirection: database.OrderDirectionDesc, + expectedOrderBy: database.Columns{database.NewColumn("instance_domains", "domain")}, + }, + { + name: "sort by domain ASC", + request: &instance.ListTrustedDomainsRequest{ + SortingColumn: instance.TrustedDomainFieldName_TRUSTED_DOMAIN_FIELD_NAME_DOMAIN, + Pagination: &filter.PaginationRequest{Asc: true}, + }, + expectedSortingDirection: database.OrderDirectionAsc, + expectedOrderBy: database.Columns{database.NewColumn("instance_domains", "domain")}, + }, + { + name: "unspecified field", + request: &instance.ListTrustedDomainsRequest{ + SortingColumn: instance.TrustedDomainFieldName_TRUSTED_DOMAIN_FIELD_NAME_UNSPECIFIED, + }, + expectedSortingDirection: database.OrderDirectionAsc, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + // Given + mockCtrl := gomock.NewController(t) + mockRepo := domainmock.NewInstancesDomainRepo(mockCtrl) + + cmd := domain.NewListInstanceTrustedDomainsQuery(tc.request) + opts := &database.QueryOpts{} + + // Test + queryOpt := cmd.Sorting(mockRepo) + queryOpt(opts) + + // Verify + assert.Equal(t, tc.expectedOrderBy, opts.OrderBy) + assert.Equal(t, tc.expectedSortingDirection, opts.Ordering) + }) + } +} diff --git a/backend/v3/domain/instance_update.go b/backend/v3/domain/instance_update.go index 8054cc73b..89a55216b 100644 --- a/backend/v3/domain/instance_update.go +++ b/backend/v3/domain/instance_update.go @@ -11,8 +11,9 @@ import ( ) type UpdateInstanceCommand struct { - ID string `json:"id"` - Name string `json:"name"` + ID string `json:"id"` + Name string `json:"name"` + ShouldSkipUpdate bool `json:"should_skip_update"` } // RequiresTransaction implements [Transactional]. @@ -20,6 +21,10 @@ func (u *UpdateInstanceCommand) RequiresTransaction() {} // Events implements Commander. func (u *UpdateInstanceCommand) Events(ctx context.Context, _ *InvokeOpts) ([]eventstore.Command, error) { + if u.ShouldSkipUpdate { + return nil, nil + } + return []eventstore.Command{ instance.NewInstanceChangedEvent(ctx, &instance.NewAggregate(u.ID).Aggregate, u.Name), }, nil @@ -39,6 +44,10 @@ func NewUpdateInstanceCommand(id, name string) *UpdateInstanceCommand { // Execute implements [Commander] func (u *UpdateInstanceCommand) Execute(ctx context.Context, opts *InvokeOpts) (err error) { + if u.ShouldSkipUpdate { + return + } + instanceRepo := opts.instanceRepo updateCount, err := instanceRepo.Update( @@ -48,16 +57,14 @@ func (u *UpdateInstanceCommand) Execute(ctx context.Context, opts *InvokeOpts) ( database.NewChange(instanceRepo.NameColumn(), u.Name), ) if err != nil { - return err + return zerrors.ThrowInternal(err, "DOM-PkVMNR", "Errors.Instance.Update") } if updateCount == 0 { - err = zerrors.ThrowNotFound(nil, "DOM-ghfov1", "Errors.Instance.NotFound") - return err + return zerrors.ThrowNotFound(nil, "DOM-ghfov1", "Errors.Instance.NotFound") } if updateCount > 1 { - err = zerrors.ThrowInternal(NewMultipleObjectsUpdatedError(1, updateCount), "DOM-HlrNmD", "unexpected number of rows updated") - return err + return zerrors.ThrowInternal(NewMultipleObjectsUpdatedError(1, updateCount), "DOM-HlrNmD", "Errors.Instance.UpdateMismatch") } return err @@ -71,26 +78,24 @@ func (u *UpdateInstanceCommand) String() string { // Validate implements [Commander] func (u *UpdateInstanceCommand) Validate(ctx context.Context, opts *InvokeOpts) error { if u.ID = strings.TrimSpace(u.ID); u.ID == "" { - return zerrors.ThrowInvalidArgument(nil, "DOM-wSs6kG", "invalid instance ID") + return zerrors.ThrowInvalidArgument(nil, "DOM-wSs6kG", "Errors.Instance.ID") } if u.Name = strings.TrimSpace(u.Name); u.Name == "" { - return zerrors.ThrowInvalidArgument(nil, "DOM-FPJcLC", "invalid instance name") + return zerrors.ThrowInvalidArgument(nil, "DOM-FPJcLC", "Errors.Instance.Name") } if authZErr := opts.Permissions.CheckInstancePermission(ctx, InstanceWritePermission); authZErr != nil { - return zerrors.ThrowPermissionDenied(authZErr, "DOM-M5ObLP", "permission denied") + return zerrors.ThrowPermissionDenied(authZErr, "DOM-M5ObLP", "Errors.PermissionDenied") } instanceRepo := opts.instanceRepo instance, err := instanceRepo.Get(ctx, opts.DB(), database.WithCondition(instanceRepo.IDCondition(u.ID))) if err != nil { - return err + return zerrors.ThrowInternal(err, "DOM-j05Hdo", "Errors.Instance.Get") } - if instance.Name == u.Name { - err = zerrors.ThrowPreconditionFailed(nil, "DOM-5MrT21", "Errors.Instance.NotChanged") - return err - } + u.ShouldSkipUpdate = instance.Name == u.Name + return nil } diff --git a/backend/v3/domain/instance_update_test.go b/backend/v3/domain/instance_update_test.go index d8f82447b..64ce0a073 100644 --- a/backend/v3/domain/instance_update_test.go +++ b/backend/v3/domain/instance_update_test.go @@ -14,6 +14,7 @@ import ( "github.com/zitadel/zitadel/backend/v3/storage/database/dbmock" noopdb "github.com/zitadel/zitadel/backend/v3/storage/database/dialect/noop" "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/repository/instance" "github.com/zitadel/zitadel/internal/zerrors" ) @@ -24,24 +25,25 @@ func TestUpdateInstanceCommand_Validate(t *testing.T) { permissionErr := errors.New("permission error") tt := []struct { - testName string - instanceRepo func(ctrl *gomock.Controller) domain.InstanceRepository - permissionChecker func(ctrl *gomock.Controller) domain.PermissionChecker - inputInstanceID string - inputInstanceName string - expectedError error + testName string + instanceRepo func(ctrl *gomock.Controller) domain.InstanceRepository + permissionChecker func(ctrl *gomock.Controller) domain.PermissionChecker + inputInstanceID string + inputInstanceName string + expectedError error + expectedUpdateSkip bool }{ { testName: "when no ID should return invalid argument error", inputInstanceID: "", inputInstanceName: "test-name", - expectedError: zerrors.ThrowInvalidArgument(nil, "DOM-wSs6kG", "invalid instance ID"), + expectedError: zerrors.ThrowInvalidArgument(nil, "DOM-wSs6kG", "Errors.Instance.ID"), }, { testName: "when no name shuld return invalid argument error", inputInstanceID: "test-id", inputInstanceName: "", - expectedError: zerrors.ThrowInvalidArgument(nil, "DOM-FPJcLC", "invalid instance name"), + expectedError: zerrors.ThrowInvalidArgument(nil, "DOM-FPJcLC", "Errors.Instance.Name"), }, { testName: "when user is missing permission should return permission denied", @@ -57,7 +59,7 @@ func TestUpdateInstanceCommand_Validate(t *testing.T) { }, inputInstanceID: "instance-1", inputInstanceName: "test instance update", - expectedError: zerrors.ThrowPermissionDenied(permissionErr, "DOM-M5ObLP", "permission denied"), + expectedError: zerrors.ThrowPermissionDenied(permissionErr, "DOM-M5ObLP", "Errors.PermissionDenied"), }, { testName: "when retrieving instance fails should return error", @@ -85,7 +87,7 @@ func TestUpdateInstanceCommand_Validate(t *testing.T) { }, inputInstanceID: "instance-1", inputInstanceName: "test instance update", - expectedError: getErr, + expectedError: zerrors.ThrowInternal(getErr, "DOM-j05Hdo", "Errors.Instance.Get"), }, { testName: "when instance name is not changed should return name not changed error", @@ -112,9 +114,9 @@ func TestUpdateInstanceCommand_Validate(t *testing.T) { }, nil) return repo }, - inputInstanceID: "instance-1", - inputInstanceName: "test instance update", - expectedError: zerrors.ThrowPreconditionFailed(nil, "DOM-5MrT21", "Errors.Instance.NotChanged"), + inputInstanceID: "instance-1", + inputInstanceName: "test instance update", + expectedUpdateSkip: true, }, { testName: "when instance name is changed should validate successfully and return no error", @@ -167,6 +169,7 @@ func TestUpdateInstanceCommand_Validate(t *testing.T) { err := cmd.Validate(ctx, opts) assert.Equal(t, tc.expectedError, err) + assert.Equal(t, tc.expectedUpdateSkip, cmd.ShouldSkipUpdate) }) } } @@ -181,11 +184,16 @@ func TestUpdateInstanceCommand_Execute(t *testing.T) { instanceRepo func(ctrl *gomock.Controller) domain.InstanceRepository - inputID string - inputName string + inputID string + inputName string + shouldSkipUpdate bool expectedError error }{ + { + testName: "when ShouldSkipUpdate is true should return nil", + shouldSkipUpdate: true, + }, { testName: "when instance update fails should return error", instanceRepo: func(ctrl *gomock.Controller) domain.InstanceRepository { @@ -201,7 +209,7 @@ func TestUpdateInstanceCommand_Execute(t *testing.T) { }, inputID: "instance-1", inputName: "test instance update", - expectedError: updateErr, + expectedError: zerrors.ThrowInternal(updateErr, "DOM-PkVMNR", "Errors.Instance.Update"), }, { testName: "when instance update returns 0 rows updated should return not found error", @@ -235,7 +243,7 @@ func TestUpdateInstanceCommand_Execute(t *testing.T) { }, inputID: "instance-1", inputName: "test instance update", - expectedError: zerrors.ThrowInternal(domain.NewMultipleObjectsUpdatedError(1, 2), "DOM-HlrNmD", "unexpected number of rows updated"), + expectedError: zerrors.ThrowInternal(domain.NewMultipleObjectsUpdatedError(1, 2), "DOM-HlrNmD", "Errors.Instance.UpdateMismatch"), }, { testName: "when instance update returns 1 row updated should return no error and set non-primary verified domain", @@ -263,6 +271,7 @@ func TestUpdateInstanceCommand_Execute(t *testing.T) { ctx := authz.NewMockContext("instance-1", "", "") ctrl := gomock.NewController(t) cmd := domain.NewUpdateInstanceCommand(tc.inputID, tc.inputName) + cmd.ShouldSkipUpdate = tc.shouldSkipUpdate opts := &domain.InvokeOpts{ Invoker: domain.NewTransactionInvoker(nil), @@ -283,18 +292,46 @@ func TestUpdateInstanceCommand_Execute(t *testing.T) { func TestUpdateInstanceCommand_Events(t *testing.T) { t.Parallel() - // Given - ctx := authz.NewMockContext("instance-1", "", "") - cmd := domain.NewUpdateInstanceCommand("instance-1", "test-name") + tt := []struct { + testName string + inputName string + inputInstanceID string + shouldSkipUpdate bool + expectedEvent eventstore.Command + }{ + { + testName: "when ShouldSkipUpdate is true should return no event and no error", + }, + { + testName: "when ShouldSkipUpdate is true should return expected event", + inputName: "test-name", + inputInstanceID: "instance-1", + expectedEvent: instance.NewInstanceChangedEvent( + t.Context(), + &instance.NewAggregate("instance-1").Aggregate, + "test-name", + ), + }, + } - // Test - events, err := cmd.Events(ctx, &domain.InvokeOpts{}) + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + // Given + ctx := authz.NewMockContext(tc.inputInstanceID, "", "") + cmd := domain.NewUpdateInstanceCommand(tc.inputInstanceID, tc.inputName) - // Verify - assert.NoError(t, err) - require.Len(t, events, 1) + // Test + events, err := cmd.Events(ctx, &domain.InvokeOpts{}) - event := events[0].(*instance.InstanceChangedEvent) - assert.Equal(t, "instance-1", event.Aggregate().ID) - assert.Equal(t, "test-name", event.Name) + // Verify + assert.NoError(t, err) + if tc.expectedEvent != nil { + require.Len(t, events, 1) + + event := events[0].(*instance.InstanceChangedEvent) + assert.Equal(t, tc.inputInstanceID, event.Aggregate().ID) + assert.Equal(t, tc.inputName, event.Name) + } + }) + } } diff --git a/backend/v3/domain/mock/instance_domain.mock.go b/backend/v3/domain/mock/instance_domain.mock.go index 2c296688f..f2871832f 100644 --- a/backend/v3/domain/mock/instance_domain.mock.go +++ b/backend/v3/domain/mock/instance_domain.mock.go @@ -353,6 +353,44 @@ func (c *MockInstanceDomainRepositoryIsGeneratedColumnCall) DoAndReturn(f func() return c } +// IsGeneratedCondition mocks base method. +func (m *MockInstanceDomainRepository) IsGeneratedCondition(arg0 bool) database.Condition { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "IsGeneratedCondition", arg0) + ret0, _ := ret[0].(database.Condition) + return ret0 +} + +// IsGeneratedCondition indicates an expected call of IsGeneratedCondition. +func (mr *MockInstanceDomainRepositoryMockRecorder) IsGeneratedCondition(arg0 any) *MockInstanceDomainRepositoryIsGeneratedConditionCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsGeneratedCondition", reflect.TypeOf((*MockInstanceDomainRepository)(nil).IsGeneratedCondition), arg0) + return &MockInstanceDomainRepositoryIsGeneratedConditionCall{Call: call} +} + +// MockInstanceDomainRepositoryIsGeneratedConditionCall wrap *gomock.Call +type MockInstanceDomainRepositoryIsGeneratedConditionCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockInstanceDomainRepositoryIsGeneratedConditionCall) Return(arg0 database.Condition) *MockInstanceDomainRepositoryIsGeneratedConditionCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockInstanceDomainRepositoryIsGeneratedConditionCall) Do(f func(bool) database.Condition) *MockInstanceDomainRepositoryIsGeneratedConditionCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockInstanceDomainRepositoryIsGeneratedConditionCall) DoAndReturn(f func(bool) database.Condition) *MockInstanceDomainRepositoryIsGeneratedConditionCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + // IsPrimaryColumn mocks base method. func (m *MockInstanceDomainRepository) IsPrimaryColumn() database.Column { m.ctrl.T.Helper() diff --git a/backend/v3/domain/oidc_configuration_update.go b/backend/v3/domain/oidc_configuration_update.go new file mode 100644 index 000000000..13301deee --- /dev/null +++ b/backend/v3/domain/oidc_configuration_update.go @@ -0,0 +1,66 @@ +package domain + +import ( + "context" + + "github.com/zitadel/zitadel/internal/eventstore" +) + +// TODO(IAM-Marco): Finish implementation +type OIDCConfigurationUpdate struct { + instanceDomainName string + projectID string + consoleAppID string + + // oidcConfigChanges []OIDConfigChange + // appID string +} + +// RequiresTransaction implements [Transactional]. +func (o *OIDCConfigurationUpdate) RequiresTransaction() {} + +func NewOIDCConfigurationUpdate(instanceDomainName, projectID, consoleAppID string) *OIDCConfigurationUpdate { + return &OIDCConfigurationUpdate{ + instanceDomainName: instanceDomainName, + projectID: projectID, + consoleAppID: consoleAppID, + } +} + +// Events implements [Commander]. +func (o *OIDCConfigurationUpdate) Events(ctx context.Context, opts *InvokeOpts) ([]eventstore.Command, error) { + // oidcConfigChangedEvent, err := project.NewOIDCConfigChangedEvent( + // ctx, + // &project.NewAggregate(o.appID, o.projectID).Aggregate, + // o.appID, + // changes, + // o.oidcConfigChanges, + // ) + // if err != nil { + // return nil, err + // } + // return []eventstore.Command{ + // oidcConfigChangedEvent, + // }, nil + return nil, nil +} + +// Execute implements [Commander]. +func (o *OIDCConfigurationUpdate) Execute(ctx context.Context, opts *InvokeOpts) (err error) { + return nil +} + +// String implements [Commander]. +func (o *OIDCConfigurationUpdate) String() string { + return "OIDCConfigurationUpdate" +} + +// Validate implements [Commander]. +func (o *OIDCConfigurationUpdate) Validate(ctx context.Context, opts *InvokeOpts) (err error) { + return nil +} + +var ( + _ (Commander) = (*OIDCConfigurationUpdate)(nil) + _ (Transactional) = (*OIDCConfigurationUpdate)(nil) +) diff --git a/backend/v3/domain/permission.go b/backend/v3/domain/permission.go index 0ccb0f84c..64eb92c3b 100644 --- a/backend/v3/domain/permission.go +++ b/backend/v3/domain/permission.go @@ -5,6 +5,8 @@ import "context" const ( InstanceReadPermission = "instance.read" InstanceWritePermission = "instance.write" + DomainReadPermission = "domain.read" + DomainWritePermission = "domain.write" OrganizationReadPermission = "organization.read" OrganizationWritePermission = "organization.write" ) diff --git a/backend/v3/storage/database/repository/instance_domain.go b/backend/v3/storage/database/repository/instance_domain.go index 709be82b5..5c2fb578e 100644 --- a/backend/v3/storage/database/repository/instance_domain.go +++ b/backend/v3/storage/database/repository/instance_domain.go @@ -163,6 +163,11 @@ func (i instanceDomain) TypeCondition(typ domain.DomainType) database.Condition return database.NewTextCondition(i.TypeColumn(), database.TextOperationEqual, typ.String()) } +// IsGeneratedCondition implements domain.InstanceDomainRepository. +func (i *instanceDomain) IsGeneratedCondition(isGenerated bool) database.Condition { + return database.NewBooleanCondition(i.IsGeneratedColumn(), isGenerated) +} + // ------------------------------------------------------------- // columns // ------------------------------------------------------------- diff --git a/internal/api/grpc/admin/instance.go b/internal/api/grpc/admin/instance.go index 38fc9ec4d..442b203de 100644 --- a/internal/api/grpc/admin/instance.go +++ b/internal/api/grpc/admin/instance.go @@ -70,7 +70,7 @@ func (s *Server) AddInstanceTrustedDomain(ctx context.Context, req *admin_pb.Add } func (s *Server) RemoveInstanceTrustedDomain(ctx context.Context, req *admin_pb.RemoveInstanceTrustedDomainRequest) (*admin_pb.RemoveInstanceTrustedDomainResponse, error) { - details, err := s.command.RemoveTrustedDomain(ctx, req.Domain) + details, err := s.command.RemoveTrustedDomain(ctx, req.Domain, true) if err != nil { return nil, err } diff --git a/internal/api/grpc/instance/v2/domain.go b/internal/api/grpc/instance/v2/domain.go index 994491053..1a5bbaeca 100644 --- a/internal/api/grpc/instance/v2/domain.go +++ b/internal/api/grpc/instance/v2/domain.go @@ -6,11 +6,17 @@ import ( "connectrpc.com/connect" "google.golang.org/protobuf/types/known/timestamppb" + instancev2 "github.com/zitadel/zitadel/backend/v3/api/instance/v2" + "github.com/zitadel/zitadel/internal/api/authz" "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) { + if authz.GetFeatures(ctx).EnableRelationalTables { + return instancev2.AddCustomDomain(ctx, req) + } + // 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()) @@ -23,18 +29,32 @@ func (s *Server) AddCustomDomain(ctx context.Context, req *connect.Request[insta } func (s *Server) RemoveCustomDomain(ctx context.Context, req *connect.Request[instance.RemoveCustomDomainRequest]) (*connect.Response[instance.RemoveCustomDomainResponse], error) { + if authz.GetFeatures(ctx).EnableRelationalTables { + return instancev2.RemoveCustomDomain(ctx, req) + } + // 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()) + details, err := s.command.RemoveInstanceDomain(ctx, req.Msg.GetCustomDomain(), false) if err != nil { return nil, err } + + var deletionDate *timestamppb.Timestamp + if details != nil { + deletionDate = timestamppb.New(details.EventDate) + } + return connect.NewResponse(&instance.RemoveCustomDomainResponse{ - DeletionDate: timestamppb.New(details.EventDate), + DeletionDate: deletionDate, }), nil } func (s *Server) AddTrustedDomain(ctx context.Context, req *connect.Request[instance.AddTrustedDomainRequest]) (*connect.Response[instance.AddTrustedDomainResponse], error) { + if authz.GetFeatures(ctx).EnableRelationalTables { + return instancev2.AddTrustedDomain(ctx, req) + } + if err := s.checkPermission(ctx, domain.PermissionSystemInstanceWrite, domain.PermissionInstanceWrite); err != nil { return nil, err } @@ -48,15 +68,23 @@ func (s *Server) AddTrustedDomain(ctx context.Context, req *connect.Request[inst } func (s *Server) RemoveTrustedDomain(ctx context.Context, req *connect.Request[instance.RemoveTrustedDomainRequest]) (*connect.Response[instance.RemoveTrustedDomainResponse], error) { + if authz.GetFeatures(ctx).EnableRelationalTables { + return instancev2.RemoveTrustedDomain(ctx, req) + } if err := s.checkPermission(ctx, domain.PermissionSystemInstanceWrite, domain.PermissionInstanceWrite); err != nil { return nil, err } - details, err := s.command.RemoveTrustedDomain(ctx, req.Msg.GetTrustedDomain()) + details, err := s.command.RemoveTrustedDomain(ctx, req.Msg.GetTrustedDomain(), false) if err != nil { return nil, err } + var deletionDate *timestamppb.Timestamp + if details != nil { + deletionDate = timestamppb.New(details.EventDate) + } + return connect.NewResponse(&instance.RemoveTrustedDomainResponse{ - DeletionDate: timestamppb.New(details.EventDate), + DeletionDate: deletionDate, }), nil } diff --git a/internal/api/grpc/instance/v2/instance.go b/internal/api/grpc/instance/v2/instance.go index cead58ed8..b5090105c 100644 --- a/internal/api/grpc/instance/v2/instance.go +++ b/internal/api/grpc/instance/v2/instance.go @@ -6,6 +6,8 @@ import ( "connectrpc.com/connect" "google.golang.org/protobuf/types/known/timestamppb" + instancev2 "github.com/zitadel/zitadel/backend/v3/api/instance/v2" + "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/pkg/grpc/instance/v2" ) @@ -13,11 +15,19 @@ import ( 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 authz.GetFeatures(ctx).EnableRelationalTables { + return instancev2.DeleteInstance(ctx, request) + } + + obj, err := s.command.RemoveInstance(ctx, request.Msg.GetInstanceId(), false) if err != nil { return nil, err } + // obj is nil when the instance is not found, so we return an empty message. + if obj == nil { + return &connect.Response[instance.DeleteInstanceResponse]{}, nil + } return connect.NewResponse(&instance.DeleteInstanceResponse{ DeletionDate: timestamppb.New(obj.EventDate), }), nil @@ -25,6 +35,10 @@ func (s *Server) DeleteInstance(ctx context.Context, request *connect.Request[in } func (s *Server) UpdateInstance(ctx context.Context, request *connect.Request[instance.UpdateInstanceRequest]) (*connect.Response[instance.UpdateInstanceResponse], error) { + if authz.GetFeatures(ctx).EnableRelationalTables { + return instancev2.UpdateInstance(ctx, request) + } + if err := s.checkPermission(ctx, domain.PermissionSystemInstanceWrite, domain.PermissionInstanceWrite); err != nil { return nil, err } diff --git a/internal/api/grpc/instance/v2/integration_test/domain_test.go b/internal/api/grpc/instance/v2/integration_test/domain_test.go index 09cdeb49b..93868e6a7 100644 --- a/internal/api/grpc/instance/v2/integration_test/domain_test.go +++ b/internal/api/grpc/instance/v2/integration_test/domain_test.go @@ -4,16 +4,19 @@ package instance_test import ( "context" + "fmt" "strings" "testing" "time" + "github.com/muhlemmer/gu" "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/feature/v2" "github.com/zitadel/zitadel/pkg/grpc/instance/v2" ) @@ -29,77 +32,95 @@ func TestAddCustomDomain(t *testing.T) { inst := integration.NewInstance(ctxWithSysAuthZ) iamOwnerCtx := inst.WithAuthorizationToken(context.Background(), integration.UserTypeIAMOwner) - t.Cleanup(func() { - inst.Client.InstanceV2.DeleteInstance(ctxWithSysAuthZ, &instance.DeleteInstanceRequest{InstanceId: inst.ID()}) + relationalInst := integration.NewInstance(ctxWithSysAuthZ) + iamOwnerRelationalCtx := relationalInst.WithAuthorizationToken(context.Background(), integration.UserTypeIAMOwner) + integration.EnsureInstanceFeature(t, ctxWithSysAuthZ, relationalInst, &feature.SetInstanceFeaturesRequest{EnableRelationalTables: gu.Ptr(true)}, func(tCollect *assert.CollectT, got *feature.GetInstanceFeaturesResponse) { + assert.True(tCollect, got.EnableRelationalTables.GetEnabled()) }) - 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(), + t.Cleanup(func() { + inst.Client.InstanceV2.DeleteInstance(ctxWithSysAuthZ, &instance.DeleteInstanceRequest{InstanceId: inst.ID()}) + relationalInst.Client.InstanceV2.DeleteInstance(ctxWithSysAuthZ, &instance.DeleteInstanceRequest{InstanceId: relationalInst.ID()}) + }) + + type instanceAndCtx struct { + testType string + instance *integration.Instance + ctx context.Context + } + testedInstances := []instanceAndCtx{ + {testType: "eventstore", instance: inst, ctx: iamOwnerCtx}, + {testType: "relational", instance: relationalInst, ctx: iamOwnerRelationalCtx}, + } + for _, instAndCtx := range testedInstances { + 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: instAndCtx.instance.ID(), + CustomDomain: integration.DomainName(), + }, + inputContext: context.Background(), + expectedErrorCode: codes.Unauthenticated, + expectedErrorMsg: "auth header missing", }, - 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(), + { + testName: "when unauthZ context should return unauthZ error", + inputRequest: &instance.AddCustomDomainRequest{ + InstanceId: instAndCtx.instance.ID(), + CustomDomain: integration.DomainName(), + }, + inputContext: instAndCtx.ctx, + expectedErrorCode: codes.PermissionDenied, + expectedErrorMsg: "No matching permissions found (AUTH-5mWD2)", }, - 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: " ", + { + testName: "when invalid domain should return invalid argument error", + inputRequest: &instance.AddCustomDomainRequest{ + InstanceId: instAndCtx.instance.ID(), + CustomDomain: " ", + }, + inputContext: ctxWithSysAuthZ, + expectedErrorCode: codes.InvalidArgument, + expectedErrorMsg: "Errors.Invalid.Argument", }, - 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(), + { + testName: "when valid request should return successful response", + inputRequest: &instance.AddCustomDomainRequest{ + InstanceId: instAndCtx.instance.ID(), + CustomDomain: " " + integration.DomainName(), + }, + inputContext: ctxWithSysAuthZ, }, - inputContext: ctxWithSysAuthZ, - }, - } + } + + for _, tc := range tt { + t.Run(fmt.Sprintf("%s - %s", instAndCtx.testType, tc.testName), func(t *testing.T) { + t.Cleanup(func() { + if tc.expectedErrorMsg == "" { + instAndCtx.instance.Client.InstanceV2.RemoveCustomDomain(ctxWithSysAuthZ, &instance.RemoveCustomDomainRequest{CustomDomain: strings.TrimSpace(tc.inputRequest.CustomDomain)}) + } + }) + + // Test + res, err := instAndCtx.instance.Client.InstanceV2.AddCustomDomain(tc.inputContext, tc.inputRequest) + + // Verify + assert.Equal(t, tc.expectedErrorCode, status.Code(err)) + assert.Contains(t, status.Convert(err).Message(), tc.expectedErrorMsg) - 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)}) + assert.NotNil(t, res) + assert.NotEmpty(t, res.GetCreationDate()) } }) - - // 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()) - } - }) + } } } @@ -107,84 +128,108 @@ func TestRemoveCustomDomain(t *testing.T) { t.Parallel() // Given - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + ctx, cancel := context.WithTimeout(t.Context(), 15*time.Minute) defer cancel() ctxWithSysAuthZ := integration.WithSystemAuthorization(ctx) - inst := integration.NewInstance(ctxWithSysAuthZ) - iamOwnerCtx := inst.WithAuthorizationToken(context.Background(), integration.UserTypeIAMOwner) + instES := integration.NewInstance(ctxWithSysAuthZ) + iamOwnerCtxES := instES.WithAuthorizationToken(t.Context(), integration.UserTypeIAMOwner) - customDomain := integration.DomainName() + instRelational := integration.NewInstance(ctxWithSysAuthZ) + iamOwnerCtxRelational := instRelational.WithAuthorizationToken(t.Context(), integration.UserTypeIAMOwner) + integration.EnsureInstanceFeature(t, ctxWithSysAuthZ, instRelational, &feature.SetInstanceFeaturesRequest{EnableRelationalTables: gu.Ptr(true)}, func(tCollect *assert.CollectT, got *feature.GetInstanceFeaturesResponse) { + assert.True(tCollect, got.EnableRelationalTables.GetEnabled()) + }) - _, err := inst.Client.InstanceV2.AddCustomDomain(ctxWithSysAuthZ, &instance.AddCustomDomainRequest{InstanceId: inst.ID(), CustomDomain: customDomain}) + customDomainES := integration.DomainName() + customDomainRelational := integration.DomainName() + + _, err := instES.Client.InstanceV2.AddCustomDomain(ctxWithSysAuthZ, &instance.AddCustomDomainRequest{InstanceId: instES.ID(), CustomDomain: customDomainES}) + require.Nil(t, err) + _, err = instRelational.Client.InstanceV2.AddCustomDomain(ctxWithSysAuthZ, &instance.AddCustomDomainRequest{InstanceId: instRelational.ID(), CustomDomain: customDomainRelational}) 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()}) + instES.Client.InstanceV2.RemoveCustomDomain(ctxWithSysAuthZ, &instance.RemoveCustomDomainRequest{InstanceId: instES.ID(), CustomDomain: customDomainES}) + instES.Client.InstanceV2.DeleteInstance(ctxWithSysAuthZ, &instance.DeleteInstanceRequest{InstanceId: instES.ID()}) + instRelational.Client.InstanceV2.RemoveCustomDomain(ctxWithSysAuthZ, &instance.RemoveCustomDomainRequest{InstanceId: instRelational.ID(), CustomDomain: customDomainRelational}) + instRelational.Client.InstanceV2.DeleteInstance(ctxWithSysAuthZ, &instance.DeleteInstanceRequest{InstanceId: instRelational.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, - }, + type instanceAndCtx struct { + testType string + instance *integration.Instance + ctx context.Context + customDomain string + } + testedInstances := []instanceAndCtx{ + {testType: "eventstore", instance: instES, ctx: iamOwnerCtxES, customDomain: customDomainES}, + {testType: "relational", instance: instRelational, ctx: iamOwnerCtxRelational, customDomain: customDomainRelational}, } - for _, tc := range tt { - t.Run(tc.testName, func(t *testing.T) { - // Test - res, err := inst.Client.InstanceV2.RemoveCustomDomain(tc.inputContext, tc.inputRequest) + for _, instAndCtx := range testedInstances { + 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: instAndCtx.instance.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: instAndCtx.instance.ID(), + CustomDomain: "custom1", + }, + inputContext: instAndCtx.ctx, + expectedErrorCode: codes.PermissionDenied, + expectedErrorMsg: "No matching permissions found (AUTH-5mWD2)", + }, + { + testName: "when invalid domain should return invalid argument error", + inputRequest: &instance.RemoveCustomDomainRequest{ + InstanceId: instAndCtx.instance.ID(), + CustomDomain: " ", + }, + inputContext: ctxWithSysAuthZ, + expectedErrorCode: codes.InvalidArgument, + expectedErrorMsg: "Errors.Invalid.Argument", + }, + { + testName: "when valid request should return successful response", + inputRequest: &instance.RemoveCustomDomainRequest{ + InstanceId: instAndCtx.instance.ID(), + CustomDomain: " " + instAndCtx.customDomain, + }, + inputContext: ctxWithSysAuthZ, + }, + } - // Verify - assert.Equal(t, tc.expectedErrorCode, status.Code(err)) - assert.Equal(t, tc.expectedErrorMsg, status.Convert(err).Message()) + for _, tc := range tt { + t.Run(fmt.Sprintf("%s - %s", instAndCtx.testType, tc.testName), func(t *testing.T) { + // Test + res, err := instAndCtx.instance.Client.InstanceV2.RemoveCustomDomain(tc.inputContext, tc.inputRequest) - if tc.expectedErrorMsg == "" { - assert.NotNil(t, res) - assert.NotEmpty(t, res.GetDeletionDate()) - } - }) + // Verify + assert.Equal(t, tc.expectedErrorCode, status.Code(err)) + assert.Contains(t, status.Convert(err).Message(), tc.expectedErrorMsg) + + if tc.expectedErrorMsg == "" { + assert.NotNil(t, res) + assert.NotEmpty(t, res.GetDeletionDate()) + } + }) + } } } @@ -196,80 +241,111 @@ func TestAddTrustedDomain(t *testing.T) { defer cancel() ctxWithSysAuthZ := integration.WithSystemAuthorization(ctx) - inst := integration.NewInstance(ctxWithSysAuthZ) - orgOwnerCtx := inst.WithAuthorizationToken(context.Background(), integration.UserTypeOrgOwner) + + // Eventstore objects + instES := integration.NewInstance(ctxWithSysAuthZ) + orgOwnerCtxES := instES.WithAuthorizationToken(context.Background(), integration.UserTypeOrgOwner) + d1ES := integration.DomainName() + + // Relational objects + instRelational := integration.NewInstance(ctxWithSysAuthZ) + integration.EnsureInstanceFeature(t, ctxWithSysAuthZ, instRelational, &feature.SetInstanceFeaturesRequest{EnableRelationalTables: gu.Ptr(true)}, func(tCollect *assert.CollectT, got *feature.GetInstanceFeaturesResponse) { + assert.True(tCollect, got.EnableRelationalTables.GetEnabled()) + }) + orgOwnerCtxRelational := instRelational.WithAuthorizationToken(context.Background(), integration.UserTypeOrgOwner) + d1Relational := integration.DomainName() t.Cleanup(func() { - inst.Client.InstanceV2.DeleteInstance(ctxWithSysAuthZ, &instance.DeleteInstanceRequest{InstanceId: inst.ID()}) + instES.Client.InstanceV2.DeleteInstance(ctxWithSysAuthZ, &instance.DeleteInstanceRequest{InstanceId: instES.ID()}) + instRelational.Client.InstanceV2.DeleteInstance(ctxWithSysAuthZ, &instance.DeleteInstanceRequest{InstanceId: instRelational.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, - }, + type instAndCtx struct { + testType string + inst *integration.Instance + orgOwnerCtx context.Context + domain string } - for _, tc := range tt { - t.Run(tc.testName, func(t *testing.T) { - t.Cleanup(func() { + testedInstanceAndCtxs := []instAndCtx{ + {testType: "eventstore", inst: instES, orgOwnerCtx: orgOwnerCtxES, domain: d1ES}, + {testType: "relational", inst: instRelational, orgOwnerCtx: orgOwnerCtxRelational, domain: d1Relational}, + } + + for _, stateCase := range testedInstanceAndCtxs { + 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: stateCase.inst.ID(), + TrustedDomain: stateCase.domain, + }, + inputContext: context.Background(), + expectedErrorCode: codes.Unauthenticated, + expectedErrorMsg: "auth header missing", + }, + { + // TODO(IAM-Marco): Fix this test for relational case when permission checks are in place (see https://github.com/zitadel/zitadel/issues/10917) + testName: "when unauthZ context should return unauthZ error", + inputRequest: &instance.AddTrustedDomainRequest{ + InstanceId: stateCase.inst.ID(), + TrustedDomain: stateCase.domain, + }, + inputContext: stateCase.orgOwnerCtx, + expectedErrorCode: codes.NotFound, + expectedErrorMsg: "membership not found (AUTHZ-cdgFk)", + }, + { + testName: "when invalid domain should return invalid argument error", + inputRequest: &instance.AddTrustedDomainRequest{ + InstanceId: stateCase.inst.ID(), + TrustedDomain: " ", + }, + inputContext: ctxWithSysAuthZ, + expectedErrorCode: codes.InvalidArgument, + expectedErrorMsg: "Errors.Invalid.Argument", + }, + { + testName: "when valid request should return successful response", + inputRequest: &instance.AddTrustedDomainRequest{ + InstanceId: stateCase.inst.ID(), + TrustedDomain: " " + stateCase.domain, + }, + inputContext: ctxWithSysAuthZ, + }, + } + + for _, tc := range tt { + // TODO(IAM-Marco): Fix this test for relational case when permission checks are in place (see https://github.com/zitadel/zitadel/issues/10917) + if tc.testName == "when unauthZ context should return unauthZ error" && stateCase.testType == "relational" { + continue + } + t.Run(fmt.Sprintf("%s - %s", stateCase.testType, tc.testName), func(t *testing.T) { + t.Cleanup(func() { + if tc.expectedErrorMsg == "" { + stateCase.inst.Client.InstanceV2.RemoveTrustedDomain(ctxWithSysAuthZ, &instance.RemoveTrustedDomainRequest{TrustedDomain: strings.TrimSpace(tc.inputRequest.TrustedDomain)}) + } + }) + + // Test + res, err := stateCase.inst.Client.InstanceV2.AddTrustedDomain(tc.inputContext, tc.inputRequest) + + // Verify + assert.Equal(t, tc.expectedErrorCode, status.Code(err)) + assert.Contains(t, status.Convert(err).Message(), tc.expectedErrorMsg) + if tc.expectedErrorMsg == "" { - inst.Client.InstanceV2.RemoveTrustedDomain(ctxWithSysAuthZ, &instance.RemoveTrustedDomainRequest{TrustedDomain: strings.TrimSpace(tc.inputRequest.TrustedDomain)}) + assert.NotNil(t, res) + assert.NotEmpty(t, res.GetCreationDate()) } }) - - // 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()) - } - }) + } } } @@ -281,69 +357,101 @@ func TestRemoveTrustedDomain(t *testing.T) { defer cancel() ctxWithSysAuthZ := integration.WithSystemAuthorization(ctx) - inst := integration.NewInstance(ctxWithSysAuthZ) - orgOwnerCtx := inst.WithAuthorizationToken(context.Background(), integration.UserTypeOrgOwner) - trustedDomain := integration.DomainName() + // Eventstore objects + instES := integration.NewInstance(ctxWithSysAuthZ) + orgOwnerCtxES := instES.WithAuthorizationToken(context.Background(), integration.UserTypeOrgOwner) + trustedDomainES := integration.DomainName() + _, err := instES.Client.InstanceV2.AddTrustedDomain(ctxWithSysAuthZ, &instance.AddTrustedDomainRequest{InstanceId: instES.ID(), TrustedDomain: trustedDomainES}) + require.Nil(t, err) - _, err := inst.Client.InstanceV2.AddTrustedDomain(ctxWithSysAuthZ, &instance.AddTrustedDomainRequest{InstanceId: inst.ID(), TrustedDomain: trustedDomain}) + // Relational objects + instRelational := integration.NewInstance(ctxWithSysAuthZ) + integration.EnsureInstanceFeature(t, ctxWithSysAuthZ, instRelational, &feature.SetInstanceFeaturesRequest{EnableRelationalTables: gu.Ptr(true)}, func(tCollect *assert.CollectT, got *feature.GetInstanceFeaturesResponse) { + assert.True(tCollect, got.EnableRelationalTables.GetEnabled()) + }) + orgOwnerCtxRelational := instRelational.WithAuthorizationToken(context.Background(), integration.UserTypeOrgOwner) + trustedDomainRelational := integration.DomainName() + _, err = instRelational.Client.InstanceV2.AddTrustedDomain(ctxWithSysAuthZ, &instance.AddTrustedDomainRequest{InstanceId: instRelational.ID(), TrustedDomain: trustedDomainRelational}) 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()}) + instES.Client.InstanceV2.RemoveTrustedDomain(ctxWithSysAuthZ, &instance.RemoveTrustedDomainRequest{InstanceId: instES.ID(), TrustedDomain: trustedDomainES}) + instES.Client.InstanceV2.DeleteInstance(ctxWithSysAuthZ, &instance.DeleteInstanceRequest{InstanceId: instES.ID()}) + + instRelational.Client.InstanceV2.RemoveTrustedDomain(ctxWithSysAuthZ, &instance.RemoveTrustedDomainRequest{InstanceId: instRelational.ID(), TrustedDomain: trustedDomainRelational}) + instRelational.Client.InstanceV2.DeleteInstance(ctxWithSysAuthZ, &instance.DeleteInstanceRequest{InstanceId: instRelational.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, - }, + type instAndCtx struct { + testType string + inst *integration.Instance + orgOwnerCtx context.Context + domain string } - for _, tc := range tt { - t.Run(tc.testName, func(t *testing.T) { - // Test - res, err := inst.Client.InstanceV2.RemoveTrustedDomain(tc.inputContext, tc.inputRequest) + testedInstanceAndCtxs := []instAndCtx{ + {testType: "eventstore", inst: instES, orgOwnerCtx: orgOwnerCtxES, domain: trustedDomainES}, + {testType: "relational", inst: instRelational, orgOwnerCtx: orgOwnerCtxRelational, domain: trustedDomainRelational}, + } - // Verify - assert.Equal(t, tc.expectedErrorCode, status.Code(err)) - assert.Equal(t, tc.expectedErrorMsg, status.Convert(err).Message()) + for _, stateCase := range testedInstanceAndCtxs { + 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: stateCase.inst.ID(), + TrustedDomain: "trusted1", + }, + inputContext: context.Background(), + expectedErrorCode: codes.Unauthenticated, + expectedErrorMsg: "auth header missing", + }, + { + // TODO(IAM-Marco): Fix this test for relational case when permission checks are in place (see https://github.com/zitadel/zitadel/issues/10917) testName: "when unauthZ context should return unauthZ error", + testName: "when unauthZ context should return unauthZ error", + inputRequest: &instance.RemoveTrustedDomainRequest{ + InstanceId: stateCase.inst.ID(), + TrustedDomain: "trusted1", + }, + inputContext: stateCase.orgOwnerCtx, + expectedErrorCode: codes.NotFound, + expectedErrorMsg: "membership not found (AUTHZ-cdgFk)", + }, + { + testName: "when valid request should return successful response", + inputRequest: &instance.RemoveTrustedDomainRequest{ + InstanceId: stateCase.inst.ID(), + TrustedDomain: " " + stateCase.domain, + }, + inputContext: ctxWithSysAuthZ, + }, + } - if tc.expectedErrorMsg == "" { - require.NotNil(t, res) - require.NotEmpty(t, res.GetDeletionDate()) + for _, tc := range tt { + // TODO(IAM-Marco): Fix this test for relational case when permission checks are in place (see https://github.com/zitadel/zitadel/issues/10917) + if tc.testName == "when unauthZ context should return unauthZ error" && stateCase.testType == "relational" { + continue } - }) + t.Run(fmt.Sprintf("%s - %s", stateCase.testType, tc.testName), func(t *testing.T) { + // Test + res, err := stateCase.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 index 57477343e..25ed4a1d0 100644 --- a/internal/api/grpc/instance/v2/integration_test/instance_test.go +++ b/internal/api/grpc/instance/v2/integration_test/instance_test.go @@ -4,15 +4,18 @@ package instance_test import ( "context" + "fmt" "testing" "time" + "github.com/muhlemmer/gu" "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/feature/v2" "github.com/zitadel/zitadel/pkg/grpc/instance/v2" ) @@ -20,105 +23,140 @@ func TestDeleteInstance(t *testing.T) { t.Parallel() // Given - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + ctx, cancel := context.WithTimeout(t.Context(), 15*time.Minute) defer cancel() ctxWithSysAuthZ := integration.WithSystemAuthorization(ctx) - inst := integration.NewInstance(ctxWithSysAuthZ) - instanceOwnerCtx := inst.WithAuthorizationToken(context.Background(), integration.UserTypeIAMOwner) - inst2 := integration.NewInstance(ctxWithSysAuthZ) + // Event store instances + instES := integration.NewInstance(ctxWithSysAuthZ) + instanceESOwnerCtx := instES.WithAuthorizationToken(t.Context(), integration.UserTypeIAMOwner) + instES2 := integration.NewInstance(ctxWithSysAuthZ) - t.Cleanup(func() { - inst.Client.InstanceV2.DeleteInstance(ctxWithSysAuthZ, &instance.DeleteInstanceRequest{InstanceId: inst.ID()}) + instRelational := integration.NewInstance(ctxWithSysAuthZ) + integration.EnsureInstanceFeature(t, ctxWithSysAuthZ, instRelational, &feature.SetInstanceFeaturesRequest{EnableRelationalTables: gu.Ptr(true)}, func(tCollect *assert.CollectT, got *feature.GetInstanceFeaturesResponse) { + assert.True(tCollect, got.EnableRelationalTables.GetEnabled()) + }) + instanceRelationalOwnerCtx := instRelational.WithAuthorizationToken(t.Context(), integration.UserTypeIAMOwner) + instRelational2 := integration.NewInstance(ctxWithSysAuthZ) + integration.EnsureInstanceFeature(t, ctxWithSysAuthZ, instRelational2, &feature.SetInstanceFeaturesRequest{EnableRelationalTables: gu.Ptr(true)}, func(tCollect *assert.CollectT, got *feature.GetInstanceFeaturesResponse) { + assert.True(tCollect, got.EnableRelationalTables.GetEnabled()) }) - 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(), - }, + type instAndCtx struct { + testType string + i1 *integration.Instance + i2 *integration.Instance + ctx context.Context } - 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()) - } - }) + testedInstancesAndCtxs := []instAndCtx{ + {testType: "eventstore", i1: instES, i2: instES2, ctx: instanceESOwnerCtx}, + {testType: "relational", i1: instRelational, i2: instRelational2, ctx: instanceRelationalOwnerCtx}, } + + t.Cleanup(func() { + instES.Client.InstanceV2.DeleteInstance(ctxWithSysAuthZ, &instance.DeleteInstanceRequest{InstanceId: instES.ID()}) + instES2.Client.InstanceV2.DeleteInstance(ctxWithSysAuthZ, &instance.DeleteInstanceRequest{InstanceId: instES2.ID()}) + instRelational.Client.InstanceV2.DeleteInstance(ctxWithSysAuthZ, &instance.DeleteInstanceRequest{InstanceId: instRelational.ID()}) + instRelational2.Client.InstanceV2.DeleteInstance(ctxWithSysAuthZ, &instance.DeleteInstanceRequest{InstanceId: instRelational2.ID()}) + }) + + for _, instanceWithCtx := range testedInstancesAndCtxs { + tt := []struct { + testName string + inputRequest *instance.DeleteInstanceRequest + inputContext context.Context + expectedErrorMsg string + expectedErrorCode codes.Code + expectedInstanceID string + expectsNullTimestamp bool + }{ + { + testName: "when invalid context should return unauthN error", + inputRequest: &instance.DeleteInstanceRequest{ + InstanceId: instanceWithCtx.i1.ID(), + }, + inputContext: t.Context(), + expectedErrorCode: codes.Unauthenticated, + expectedErrorMsg: "auth header missing", + }, + { + testName: "when invalid context and invalid id should return unauthN error", + inputRequest: &instance.DeleteInstanceRequest{ + InstanceId: instanceWithCtx.i1.ID() + "invalid", + }, + inputContext: t.Context(), + expectedErrorCode: codes.Unauthenticated, + expectedErrorMsg: "auth header missing", + }, + { + testName: "when instance token for invalid instance should return unauthN error", + inputRequest: &instance.DeleteInstanceRequest{ + InstanceId: instanceWithCtx.i1.ID() + "invalid", + }, + inputContext: instanceWithCtx.ctx, + expectedErrorCode: codes.Unauthenticated, + expectedErrorMsg: "Errors.Token.Invalid (AUTH-7fs1e)", + }, + { + testName: "when instance token for other instance should return unauthN error", + inputRequest: &instance.DeleteInstanceRequest{ + InstanceId: instanceWithCtx.i2.ID(), + }, + inputContext: instanceWithCtx.ctx, + expectedErrorCode: codes.Unauthenticated, + expectedErrorMsg: "Errors.Token.Invalid (AUTH-7fs1e)", + }, + { + testName: "when instance token for own instance should return unauthZ error", + inputRequest: &instance.DeleteInstanceRequest{ + InstanceId: instanceWithCtx.i1.ID(), + }, + inputContext: instanceWithCtx.ctx, + expectedErrorCode: codes.PermissionDenied, + expectedErrorMsg: "No matching permissions found (AUTH-5mWD2)", + }, + { + // TODO(IAM-Marco): This test won't reach the relational side due to an interceptor + // automatically changing the instance in context to an empty one. + // The interceptor will look for the instance matching the instance ID passed here. + // Since the instance doesn't exist, it will put an empty instance in context. + // But an empty instance has no feature flags enabled. + testName: "when invalid id should return no error", + inputRequest: &instance.DeleteInstanceRequest{ + InstanceId: instanceWithCtx.i1.ID() + "invalid", + }, + inputContext: ctxWithSysAuthZ, + expectsNullTimestamp: true, + }, + { + testName: "when delete succeeds should return deletion date", + inputRequest: &instance.DeleteInstanceRequest{ + InstanceId: instanceWithCtx.i1.ID(), + }, + inputContext: ctxWithSysAuthZ, + expectedInstanceID: instanceWithCtx.i1.ID(), + }, + } + + for _, tc := range tt { + t.Run(fmt.Sprintf("%s - %s", instanceWithCtx.testType, tc.testName), func(t *testing.T) { + // Test + res, err := instanceWithCtx.i1.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.Equal(t, tc.expectsNullTimestamp, res.GetDeletionDate() == nil) + } + }) + } + + } + } func TestUpdateInstance(t *testing.T) { @@ -130,123 +168,163 @@ func TestUpdateInstance(t *testing.T) { 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) + instES := integration.NewInstance(ctxWithSysAuthZ) + instanceOwnerCtxES := instES.WithAuthorizationToken(context.Background(), integration.UserTypeIAMOwner) + orgOwnerCtxES := instES.WithAuthorizationToken(context.Background(), integration.UserTypeOrgOwner) + inst2ES := integration.NewInstance(ctxWithSysAuthZ) - t.Cleanup(func() { - inst.Client.InstanceV2.DeleteInstance(ctxWithSysAuthZ, &instance.DeleteInstanceRequest{InstanceId: inst.ID()}) + // Relational + instRelational := integration.NewInstance(ctxWithSysAuthZ) + integration.EnsureInstanceFeature(t, ctxWithSysAuthZ, instRelational, &feature.SetInstanceFeaturesRequest{EnableRelationalTables: gu.Ptr(true)}, func(tCollect *assert.CollectT, got *feature.GetInstanceFeaturesResponse) { + assert.True(tCollect, got.EnableRelationalTables.GetEnabled()) + }) + instanceOwnerCtxRelational := instRelational.WithAuthorizationToken(context.Background(), integration.UserTypeIAMOwner) + orgOwnerCtxRelational := instRelational.WithAuthorizationToken(context.Background(), integration.UserTypeOrgOwner) + inst2Relational := integration.NewInstance(ctxWithSysAuthZ) + integration.EnsureInstanceFeature(t, ctxWithSysAuthZ, inst2Relational, &feature.SetInstanceFeaturesRequest{EnableRelationalTables: gu.Ptr(true)}, func(tCollect *assert.CollectT, got *feature.GetInstanceFeaturesResponse) { + assert.True(tCollect, got.EnableRelationalTables.GetEnabled()) }) - 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", - }, + t.Cleanup(func() { + instES.Client.InstanceV2.DeleteInstance(ctxWithSysAuthZ, &instance.DeleteInstanceRequest{InstanceId: instES.ID()}) + inst2ES.Client.InstanceV2.DeleteInstance(ctxWithSysAuthZ, &instance.DeleteInstanceRequest{InstanceId: inst2ES.ID()}) + instRelational.Client.InstanceV2.DeleteInstance(ctxWithSysAuthZ, &instance.DeleteInstanceRequest{InstanceId: instRelational.ID()}) + inst2Relational.Client.InstanceV2.DeleteInstance(ctxWithSysAuthZ, &instance.DeleteInstanceRequest{InstanceId: inst2Relational.ID()}) + }) + + type instAndCtx struct { + testType string + inst *integration.Instance + inst2 *integration.Instance + instanceOwnerCtx context.Context + orgOwnerCtx context.Context } - for _, tc := range tt { - t.Run(tc.testName, func(t *testing.T) { - // Test - res, err := inst.Client.InstanceV2.UpdateInstance(tc.inputContext, tc.inputRequest) + testedInstancesAndCtxs := []instAndCtx{ + {testType: "eventstore", inst: instES, inst2: inst2ES, instanceOwnerCtx: instanceOwnerCtxES, orgOwnerCtx: orgOwnerCtxES}, + {testType: "relational", inst: instRelational, inst2: inst2Relational, instanceOwnerCtx: instanceOwnerCtxRelational, orgOwnerCtx: orgOwnerCtxRelational}, + } - // Verify - assert.Equal(t, tc.expectedErrorCode, status.Code(err)) - assert.Equal(t, tc.expectedErrorMsg, status.Convert(err).Message()) - if tc.expectedErrorMsg == "" { + for _, instancesWithCtx := range testedInstancesAndCtxs { + 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: instancesWithCtx.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: instancesWithCtx.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: instancesWithCtx.inst.ID() + "invalid", + InstanceName: " ", + }, + inputContext: instancesWithCtx.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: instancesWithCtx.inst2.ID(), + InstanceName: " ", + }, + inputContext: instancesWithCtx.instanceOwnerCtx, + expectedErrorCode: codes.Unauthenticated, + expectedErrorMsg: "Errors.Token.Invalid (AUTH-7fs1e)", + }, + { + // TODO(IAM-Marco): Fix this test for relational case when permission checks are in place (see https://github.com/zitadel/zitadel/issues/10917) + testName: "when unauthZ context should return unauthZ error", + inputRequest: &instance.UpdateInstanceRequest{ + InstanceId: instancesWithCtx.inst.ID(), + InstanceName: "a valid name", + }, + inputContext: instancesWithCtx.orgOwnerCtx, + expectedErrorCode: codes.NotFound, + expectedErrorMsg: "membership not found (AUTHZ-cdgFk)", + }, + { + // TODO(IAM-Marco): This test won't reach the relational side due to an interceptor + // automatically changing the instance in context to an empty one. + // The interceptor will look for the instance matching the instance ID passed here. + // Since the instance doesn't exist, it will put an empty instance in context. + // But an empty instance has no feature flags enabled. + testName: "when invalid id should return not found error", + inputRequest: &instance.UpdateInstanceRequest{ + InstanceId: instancesWithCtx.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: instancesWithCtx.inst.ID(), + InstanceName: "an-updated-name", + }, + inputContext: instancesWithCtx.instanceOwnerCtx, + expectedNewName: "an-updated-name", + }, + { + testName: "when update succeeds should change instance name", + inputRequest: &instance.UpdateInstanceRequest{ + InstanceId: instancesWithCtx.inst2.ID(), + InstanceName: "an-updated-name2", + }, + inputContext: ctxWithSysAuthZ, + expectedNewName: "an-updated-name2", + }, + } - 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") + for _, tc := range tt { + // TODO(IAM-Marco): Fix this test for relational case when permission checks are in place (see https://github.com/zitadel/zitadel/issues/10917) + if tc.testName == "when unauthZ context should return unauthZ error" && instancesWithCtx.testType == "relational" { + continue } - }) + t.Run(fmt.Sprintf("%s - %s", instancesWithCtx.testType, tc.testName), func(t *testing.T) { + // Test + res, err := instES.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 := instES.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 index 87bb37bdd..dad3c6a24 100644 --- a/internal/api/grpc/instance/v2/integration_test/query_test.go +++ b/internal/api/grpc/instance/v2/integration_test/query_test.go @@ -4,16 +4,19 @@ package instance_test import ( "context" + "fmt" "slices" "testing" "time" + "github.com/muhlemmer/gu" "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/feature/v2" "github.com/zitadel/zitadel/pkg/grpc/filter/v2" "github.com/zitadel/zitadel/pkg/grpc/instance/v2" "github.com/zitadel/zitadel/pkg/grpc/object/v2" @@ -27,70 +30,110 @@ func TestGetInstance(t *testing.T) { defer cancel() ctxWithSysAuthZ := integration.WithSystemAuthorization(ctx) - inst := integration.NewInstance(ctxWithSysAuthZ) - instanceOwnerCtx := inst.WithAuthorizationToken(context.Background(), integration.UserTypeIAMOwner) - organizationOwnerCtx := inst.WithAuthorizationToken(context.Background(), integration.UserTypeOrgOwner) + instES := integration.NewInstance(ctxWithSysAuthZ) + instanceOwnerCtxES := instES.WithAuthorizationToken(context.Background(), integration.UserTypeIAMOwner) + organizationOwnerCtxES := instES.WithAuthorizationToken(context.Background(), integration.UserTypeOrgOwner) + + // Relational + instRelational := integration.NewInstance(ctxWithSysAuthZ) + integration.EnsureInstanceFeature(t, ctxWithSysAuthZ, instRelational, &feature.SetInstanceFeaturesRequest{EnableRelationalTables: gu.Ptr(true)}, func(tCollect *assert.CollectT, got *feature.GetInstanceFeaturesResponse) { + assert.True(tCollect, got.EnableRelationalTables.GetEnabled()) + }) + instanceOwnerCtxRelational := instRelational.WithAuthorizationToken(context.Background(), integration.UserTypeIAMOwner) + organizationOwnerCtxRelational := instRelational.WithAuthorizationToken(context.Background(), integration.UserTypeOrgOwner) t.Cleanup(func() { - inst.Client.InstanceV2.DeleteInstance(ctxWithSysAuthZ, &instance.DeleteInstanceRequest{InstanceId: inst.ID()}) + instES.Client.InstanceV2.DeleteInstance(ctxWithSysAuthZ, &instance.DeleteInstanceRequest{InstanceId: instES.ID()}) + instRelational.Client.InstanceV2.DeleteInstance(ctxWithSysAuthZ, &instance.DeleteInstanceRequest{InstanceId: instRelational.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)", - }, + type instAndCtx struct { + testType string + inst *integration.Instance + instanceOwnerCtx context.Context + orgOwnerCtx context.Context } - 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}) + testedInstanceAndCtxs := []instAndCtx{ + {testType: "eventstore", inst: instES, instanceOwnerCtx: instanceOwnerCtxES, orgOwnerCtx: organizationOwnerCtxES}, + {testType: "relational", inst: instRelational, instanceOwnerCtx: instanceOwnerCtxRelational, orgOwnerCtx: organizationOwnerCtxRelational}, + } - // Verify - assert.Equal(t, tc.expectedErrorCode, status.Code(err)) - assert.Equal(t, tc.expectedErrorMsg, status.Convert(err).Message()) + for _, instWithCtx := range testedInstanceAndCtxs { + 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: instWithCtx.inst.ID(), + expectedErrorCode: codes.Unauthenticated, + expectedErrorMsg: "auth header missing", + }, + { + // TODO(IAM-Marco): Fix this test for relational case when permission checks are in place (see https://github.com/zitadel/zitadel/issues/10917) + testName: "when unauthZ context should return unauthZ error", + inputContext: instWithCtx.orgOwnerCtx, + inputInstanceID: instWithCtx.inst.ID(), + expectedErrorCode: codes.NotFound, + expectedErrorMsg: "membership not found (AUTHZ-cdgFk)", + }, + { + testName: "when request succeeds should return matching instance (systemUser)", + inputContext: ctxWithSysAuthZ, + inputInstanceID: instWithCtx.inst.ID(), + expectedInstanceID: instWithCtx.inst.ID(), + }, + { + // TODO(IAM-Marco): Decide if we should set the instance in context. + testName: "when request succeeds should return matching instance (own context)", + inputContext: instWithCtx.instanceOwnerCtx, + expectedInstanceID: instWithCtx.inst.ID(), + }, + { + // TODO(IAM-Marco): This test won't reach the relational side due to an interceptor + // automatically changing the instance in context to an empty one. + // The interceptor will look for the instance matching the instance ID passed here. + // Since the instance doesn't exist, it will put an empty instance in context. + // But an empty instance has no feature flags enabled. + testName: "when instance not found should return not found error", + inputContext: ctxWithSysAuthZ, + inputInstanceID: "invalid", + expectedErrorCode: codes.NotFound, + expectedErrorMsg: "Errors.IAM.NotFound (QUERY-n0wng)", + }, + } - if tc.expectedErrorMsg == "" { - require.NoError(t, err) - assert.Equal(t, tc.expectedInstanceID, res.GetInstance().GetId()) + faultyTestCasesForRelational := []string{ + // TODO(IAM-Marco): Fix this test for relational case when permission checks are in place (see https://github.com/zitadel/zitadel/issues/10917) + "when unauthZ context should return unauthZ error", + // TODO(IAM-Marco): Decide if we should set the instance in context. + "when request succeeds should return matching instance (own context)", + } + + for _, tc := range tt { + if instWithCtx.testType == "relational" && slices.Contains(faultyTestCasesForRelational, tc.testName) { + continue } - }) + t.Run(fmt.Sprintf("%s - %s", instWithCtx.testType, tc.testName), func(t *testing.T) { + // Test + res, err := instWithCtx.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()) + } + }) + } } } @@ -103,27 +146,27 @@ func TestListInstances(t *testing.T) { ctxWithSysAuthZ := integration.WithSystemAuthorization(ctx) - instances := make([]*integration.Instance, 2) + instances := make([]*integration.Instance, 5) inst := integration.NewInstance(ctxWithSysAuthZ) inst2 := integration.NewInstance(ctxWithSysAuthZ) - instances[0], instances[1] = inst, inst2 + inst3 := integration.NewInstance(ctxWithSysAuthZ) + inst4 := integration.NewInstance(ctxWithSysAuthZ) + inst5 := integration.NewInstance(ctxWithSysAuthZ) + instances[0], instances[1], instances[2], instances[3], instances[4] = inst, inst2, inst3, inst4, inst5 t.Cleanup(func() { + inst.Client.FeatureV2.ResetInstanceFeatures(ctxWithSysAuthZ, &feature.ResetInstanceFeaturesRequest{}) 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 + inst2.Client.InstanceV2.DeleteInstance(ctxWithSysAuthZ, &instance.DeleteInstanceRequest{InstanceId: inst2.ID()}) + inst3.Client.InstanceV2.DeleteInstance(ctxWithSysAuthZ, &instance.DeleteInstanceRequest{InstanceId: inst3.ID()}) + inst4.Client.InstanceV2.DeleteInstance(ctxWithSysAuthZ, &instance.DeleteInstanceRequest{InstanceId: inst4.ID()}) + inst5.Client.InstanceV2.DeleteInstance(ctxWithSysAuthZ, &instance.DeleteInstanceRequest{InstanceId: inst5.ID()}) }) orgOwnerCtx := inst.WithAuthorizationToken(context.Background(), integration.UserTypeOrgOwner) + relTableState := integration.RelationalTablesEnableMatrix() + tt := []struct { testName string inputRequest *instance.ListInstancesRequest @@ -142,6 +185,7 @@ func TestListInstances(t *testing.T) { expectedErrorMsg: "auth header missing", }, { + // TODO(IAM-Marco): Fix this test for relational case when permission checks are in place (see https://github.com/zitadel/zitadel/issues/10917) testName: "when unauthZ context should return unauthZ error", inputRequest: &instance.ListInstancesRequest{ Pagination: &filter.PaginationRequest{Offset: 0, Limit: 10}, @@ -153,43 +197,61 @@ func TestListInstances(t *testing.T) { { testName: "when valid request with filter should return paginated response", inputRequest: &instance.ListInstancesRequest{ - Pagination: &filter.PaginationRequest{Offset: 0, Limit: 10}, + Pagination: &filter.PaginationRequest{Offset: 1, Limit: 3}, SortingColumn: instance.FieldName_FIELD_NAME_CREATION_DATE, Filters: []*instance.Filter{ { Filter: &instance.Filter_InIdsFilter{ InIdsFilter: &filter.InIDsFilter{ - Ids: []string{inst.ID(), inst2.ID()}, + Ids: []string{inst.ID(), inst2.ID(), inst3.ID(), inst4.ID(), inst5.ID()}, + }, + }, + }, + { + Filter: &instance.Filter_CustomDomainsFilter{ + CustomDomainsFilter: &instance.CustomDomainsFilter{ + Domains: []string{inst4.Domain, inst3.Domain, inst2.Domain, inst.Domain}, }, }, }, }, }, inputContext: ctxWithSysAuthZ, - expectedInstances: []string{inst2.ID(), inst.ID()}, + expectedInstances: []string{inst3.ID(), 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()) - } - } + for _, stateCase := range relTableState { + integration.EnsureInstanceFeature(t, ctx, inst, stateCase.FeatureSet, func(tCollect *assert.CollectT, got *feature.GetInstanceFeaturesResponse) { + assert.Equal(tCollect, stateCase.FeatureSet.GetEnableRelationalTables(), got.EnableRelationalTables.GetEnabled()) }) + + for _, tc := range tt { + // TODO(IAM-Marco): Fix this test for relational case when permission checks are in place (see https://github.com/zitadel/zitadel/issues/10917) + if tc.testName == "when unauthZ context should return unauthZ error" && stateCase.State == "when relational tables are enabled" { + continue + } + t.Run(fmt.Sprintf("%s - %s", stateCase.State, 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) { @@ -200,115 +262,150 @@ func TestListCustomDomains(t *testing.T) { 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}) + // Eventstore objects + instES := integration.NewInstance(ctxWithSysAuthZ) + orgOwnerCtxES := instES.WithAuthorizationToken(context.Background(), integration.UserTypeOrgOwner) + instanceOwnerCtxES := instES.WithAuthorizationToken(context.Background(), integration.UserTypeIAMOwner) + d1ES, d2ES := "custom."+integration.DomainName(), "custom."+integration.DomainName() + _, err := instES.Client.InstanceV2.AddCustomDomain(ctxWithSysAuthZ, &instance.AddCustomDomainRequest{InstanceId: instES.ID(), CustomDomain: d1ES}) require.Nil(t, err) - _, err = inst.Client.InstanceV2.AddCustomDomain(ctxWithSysAuthZ, &instance.AddCustomDomainRequest{InstanceId: inst.ID(), CustomDomain: d2}) + _, err = instES.Client.InstanceV2.AddCustomDomain(ctxWithSysAuthZ, &instance.AddCustomDomainRequest{InstanceId: instES.ID(), CustomDomain: d2ES}) + require.Nil(t, err) + + // Relational objects + instRelational := integration.NewInstance(ctxWithSysAuthZ) + integration.EnsureInstanceFeature(t, ctx, instRelational, &feature.SetInstanceFeaturesRequest{EnableRelationalTables: gu.Ptr(true)}, func(tCollect *assert.CollectT, got *feature.GetInstanceFeaturesResponse) { + assert.True(tCollect, got.EnableRelationalTables.GetEnabled()) + }) + orgOwnerCtxRelational := instRelational.WithAuthorizationToken(context.Background(), integration.UserTypeOrgOwner) + instanceOwnerCtxRelational := instRelational.WithAuthorizationToken(context.Background(), integration.UserTypeIAMOwner) + d1Relational, d2Relational := "custom."+integration.DomainName(), "custom."+integration.DomainName() + _, err = instRelational.Client.InstanceV2.AddCustomDomain(ctxWithSysAuthZ, &instance.AddCustomDomainRequest{InstanceId: instRelational.ID(), CustomDomain: d1Relational}) + require.Nil(t, err) + _, err = instRelational.Client.InstanceV2.AddCustomDomain(ctxWithSysAuthZ, &instance.AddCustomDomainRequest{InstanceId: instRelational.ID(), CustomDomain: d2Relational}) 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()}) + instES.Client.InstanceV2.RemoveCustomDomain(ctxWithSysAuthZ, &instance.RemoveCustomDomainRequest{InstanceId: instES.ID(), CustomDomain: d1ES}) + instES.Client.InstanceV2.RemoveCustomDomain(ctxWithSysAuthZ, &instance.RemoveCustomDomainRequest{InstanceId: instES.ID(), CustomDomain: d2ES}) + instES.Client.InstanceV2.DeleteInstance(ctxWithSysAuthZ, &instance.DeleteInstanceRequest{InstanceId: instES.ID()}) + + instRelational.Client.InstanceV2.RemoveCustomDomain(ctxWithSysAuthZ, &instance.RemoveCustomDomainRequest{InstanceId: instRelational.ID(), CustomDomain: d1Relational}) + instRelational.Client.InstanceV2.RemoveCustomDomain(ctxWithSysAuthZ, &instance.RemoveCustomDomainRequest{InstanceId: instRelational.ID(), CustomDomain: d2Relational}) + instRelational.Client.InstanceV2.DeleteInstance(ctxWithSysAuthZ, &instance.DeleteInstanceRequest{InstanceId: instRelational.ID()}) }) - tt := []struct { - testName string - inputRequest *instance.ListCustomDomainsRequest - inputContext context.Context - expectedErrorMsg string - expectedErrorCode codes.Code - expectedDomains []string + testData := []struct { + testType string + inst *integration.Instance + sysOwnerCtx context.Context + instOwnerCtx context.Context + orgOwnerCtx context.Context + 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}, - }, + {testType: "eventstore", inst: instES, sysOwnerCtx: ctxWithSysAuthZ, instOwnerCtx: instanceOwnerCtxES, orgOwnerCtx: orgOwnerCtxES, expectedDomains: []string{d1ES, d2ES}}, + {testType: "relational", inst: instRelational, sysOwnerCtx: ctxWithSysAuthZ, instOwnerCtx: instanceOwnerCtxRelational, orgOwnerCtx: orgOwnerCtxRelational, expectedDomains: []string{d1Relational, d2Relational}}, } + for _, td := range testData { - 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) + 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: td.inst.ID(), + Pagination: &filter.PaginationRequest{Offset: 0, Limit: 10}, + }, + inputContext: context.Background(), + expectedErrorCode: codes.Unauthenticated, + expectedErrorMsg: "auth header missing", + }, + { + // TODO(IAM-Marco): Fix this test for relational case when permission checks are in place (see https://github.com/zitadel/zitadel/issues/10917) + testName: "when unauthZ context should return unauthZ error", + inputRequest: &instance.ListCustomDomainsRequest{ + InstanceId: td.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: td.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: td.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: td.sysOwnerCtx, + expectedDomains: td.expectedDomains, + }, + { + 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: td.instOwnerCtx, + expectedDomains: []string{td.expectedDomains[0]}, + }, + } - // Verify - assert.Equal(collect, tc.expectedErrorCode, status.Code(err)) - assert.Equal(collect, tc.expectedErrorMsg, status.Convert(err).Message()) + for _, tc := range tt { + if tc.testName == "when unauthZ context should return unauthZ error" && td.testType == "relational" { + continue + } + t.Run(fmt.Sprintf("%s - %s", td.testType, tc.testName), func(t *testing.T) { + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(tc.inputContext, time.Minute) + require.EventuallyWithT(t, func(collect *assert.CollectT) { + // Test + res, err := td.inst.Client.InstanceV2.ListCustomDomains(tc.inputContext, tc.inputRequest) - if tc.expectedErrorMsg == "" { - domains := []string{} - for _, d := range res.GetDomains() { - domains = append(domains, d.GetDomain()) + // 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) } - - assert.Subset(collect, domains, tc.expectedDomains) - } - }, retryDuration, tick) - }) + }, retryDuration, tick) + }) + } } } @@ -320,108 +417,144 @@ func TestListTrustedDomains(t *testing.T) { 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}) + // Eventstore objects + instES := integration.NewInstance(ctxWithSysAuthZ) + orgOwnerCtxES := instES.WithAuthorizationToken(context.Background(), integration.UserTypeOrgOwner) + instanceOwnerCtxES := instES.WithAuthorizationToken(context.Background(), integration.UserTypeIAMOwner) + d1ES, d2ES := "trusted."+integration.DomainName(), "trusted."+integration.DomainName() + _, err := instES.Client.InstanceV2.AddTrustedDomain(ctxWithSysAuthZ, &instance.AddTrustedDomainRequest{InstanceId: instES.ID(), TrustedDomain: d1ES}) require.Nil(t, err) - _, err = inst.Client.InstanceV2.AddTrustedDomain(ctxWithSysAuthZ, &instance.AddTrustedDomainRequest{InstanceId: inst.ID(), TrustedDomain: d2}) + _, err = instES.Client.InstanceV2.AddTrustedDomain(ctxWithSysAuthZ, &instance.AddTrustedDomainRequest{InstanceId: instES.ID(), TrustedDomain: d2ES}) + require.Nil(t, err) + + // Relational objects + instRelational := integration.NewInstance(ctxWithSysAuthZ) + integration.EnsureInstanceFeature(t, ctx, instRelational, &feature.SetInstanceFeaturesRequest{EnableRelationalTables: gu.Ptr(true)}, func(tCollect *assert.CollectT, got *feature.GetInstanceFeaturesResponse) { + assert.True(tCollect, got.EnableRelationalTables.GetEnabled()) + }) + orgOwnerCtxRelational := instRelational.WithAuthorizationToken(context.Background(), integration.UserTypeOrgOwner) + instanceOwnerCtxRelational := instRelational.WithAuthorizationToken(context.Background(), integration.UserTypeIAMOwner) + d1Relational, d2Relational := "trusted."+integration.DomainName(), "trusted."+integration.DomainName() + _, err = instRelational.Client.InstanceV2.AddTrustedDomain(ctxWithSysAuthZ, &instance.AddTrustedDomainRequest{InstanceId: instRelational.ID(), TrustedDomain: d1Relational}) + require.Nil(t, err) + _, err = instRelational.Client.InstanceV2.AddTrustedDomain(ctxWithSysAuthZ, &instance.AddTrustedDomainRequest{InstanceId: instRelational.ID(), TrustedDomain: d2Relational}) 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()}) + instES.Client.InstanceV2.RemoveTrustedDomain(ctxWithSysAuthZ, &instance.RemoveTrustedDomainRequest{InstanceId: instES.ID(), TrustedDomain: d1ES}) + instES.Client.InstanceV2.RemoveTrustedDomain(ctxWithSysAuthZ, &instance.RemoveTrustedDomainRequest{InstanceId: instES.ID(), TrustedDomain: d2ES}) + instES.Client.InstanceV2.DeleteInstance(ctxWithSysAuthZ, &instance.DeleteInstanceRequest{InstanceId: instES.ID()}) + + instRelational.Client.InstanceV2.RemoveTrustedDomain(ctxWithSysAuthZ, &instance.RemoveTrustedDomainRequest{InstanceId: instRelational.ID(), TrustedDomain: d1Relational}) + instRelational.Client.InstanceV2.RemoveTrustedDomain(ctxWithSysAuthZ, &instance.RemoveTrustedDomainRequest{InstanceId: instRelational.ID(), TrustedDomain: d2Relational}) + instRelational.Client.InstanceV2.DeleteInstance(ctxWithSysAuthZ, &instance.DeleteInstanceRequest{InstanceId: instRelational.ID()}) }) - tt := []struct { - testName string - inputRequest *instance.ListTrustedDomainsRequest - inputContext context.Context - expectedErrorMsg string - expectedErrorCode codes.Code - expectedDomains []string + testData := []struct { + testType string + inst *integration.Instance + sysOwnerCtx context.Context + instOwnerCtx context.Context + orgOwnerCtx context.Context + 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}, - }, + {testType: "eventstore", inst: instES, sysOwnerCtx: ctxWithSysAuthZ, instOwnerCtx: instanceOwnerCtxES, orgOwnerCtx: orgOwnerCtxES, expectedDomains: []string{d1ES, d2ES}}, + {testType: "relational", inst: instRelational, sysOwnerCtx: ctxWithSysAuthZ, instOwnerCtx: instanceOwnerCtxRelational, orgOwnerCtx: orgOwnerCtxRelational, expectedDomains: []string{d1Relational, d2Relational}}, } + for _, td := range testData { + 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: td.inst.ID(), + Pagination: &filter.PaginationRequest{Offset: 0, Limit: 10}, + }, + inputContext: context.Background(), + expectedErrorCode: codes.Unauthenticated, + expectedErrorMsg: "auth header missing", + }, + { + // TODO(IAM-Marco): Fix this test for relational case when permission checks are in place (see https://github.com/zitadel/zitadel/issues/10917) + testName: "when unauthZ context should return unauthZ error", + inputRequest: &instance.ListTrustedDomainsRequest{ + InstanceId: td.inst.ID(), + Pagination: &filter.PaginationRequest{Offset: 0, Limit: 10}, + }, + inputContext: td.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: td.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: td.sysOwnerCtx, + expectedDomains: td.expectedDomains, + }, + { + 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: td.instOwnerCtx, + expectedDomains: []string{td.expectedDomains[0]}, + }, + } - 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) + for _, tc := range tt { + // TODO(IAM-Marco): Fix this test for relational case when permission checks are in place (see https://github.com/zitadel/zitadel/issues/10917) + if tc.testName == "when unauthZ context should return unauthZ error" && td.testType == "relational" { + continue + } - // Verify - assert.Equal(collect, tc.expectedErrorCode, status.Code(err)) - assert.Equal(collect, tc.expectedErrorMsg, status.Convert(err).Message()) + t.Run(fmt.Sprintf("%s - %s", td.testType, tc.testName), func(t *testing.T) { + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(tc.inputContext, time.Minute) + require.EventuallyWithT(t, func(collect *assert.CollectT) { + // Test + res, err := td.inst.Client.InstanceV2.ListTrustedDomains(tc.inputContext, tc.inputRequest) - if tc.expectedErrorMsg == "" { - require.NotNil(t, res) + // Verify + assert.Equal(collect, tc.expectedErrorCode, status.Code(err)) + assert.Equal(collect, tc.expectedErrorMsg, status.Convert(err).Message()) - domains := []string{} - for _, d := range res.GetTrustedDomain() { - domains = append(domains, d.GetDomain()) + 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) } - - assert.Subset(collect, domains, tc.expectedDomains) - } - }, retryDuration, tick) - }) + }, retryDuration, tick) + }) + } } } diff --git a/internal/api/grpc/instance/v2/query.go b/internal/api/grpc/instance/v2/query.go index d764bdc92..71a36f15b 100644 --- a/internal/api/grpc/instance/v2/query.go +++ b/internal/api/grpc/instance/v2/query.go @@ -5,12 +5,17 @@ import ( "connectrpc.com/connect" + instancev2 "github.com/zitadel/zitadel/backend/v3/api/instance/v2" + "github.com/zitadel/zitadel/internal/api/authz" "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) { +func (s *Server) GetInstance(ctx context.Context, req *connect.Request[instance.GetInstanceRequest]) (*connect.Response[instance.GetInstanceResponse], error) { + if authz.GetFeatures(ctx).EnableRelationalTables { + return instancev2.GetInstance(ctx, req) + } if err := s.checkPermission(ctx, domain.PermissionSystemInstanceRead, domain.PermissionInstanceRead); err != nil { return nil, err } @@ -25,6 +30,10 @@ func (s *Server) GetInstance(ctx context.Context, _ *connect.Request[instance.Ge } func (s *Server) ListInstances(ctx context.Context, req *connect.Request[instance.ListInstancesRequest]) (*connect.Response[instance.ListInstancesResponse], error) { + if authz.GetFeatures(ctx).EnableRelationalTables { + return instancev2.ListInstances(ctx, req) + } + // 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) @@ -44,6 +53,10 @@ func (s *Server) ListInstances(ctx context.Context, req *connect.Request[instanc } func (s *Server) ListCustomDomains(ctx context.Context, req *connect.Request[instance.ListCustomDomainsRequest]) (*connect.Response[instance.ListCustomDomainsResponse], error) { + if authz.GetFeatures(ctx).EnableRelationalTables { + return instancev2.ListCustomDomains(ctx, req) + } + if err := s.checkPermission(ctx, domain.PermissionSystemInstanceRead, domain.PermissionInstanceRead); err != nil { return nil, err } @@ -64,6 +77,10 @@ func (s *Server) ListCustomDomains(ctx context.Context, req *connect.Request[ins } func (s *Server) ListTrustedDomains(ctx context.Context, req *connect.Request[instance.ListTrustedDomainsRequest]) (*connect.Response[instance.ListTrustedDomainsResponse], error) { + if authz.GetFeatures(ctx).EnableRelationalTables { + return instancev2.ListTrustedDomains(ctx, req) + } + if err := s.checkPermission(ctx, domain.PermissionSystemInstanceRead, domain.PermissionInstanceRead); err != nil { return nil, err } diff --git a/internal/api/grpc/instance/v2beta/domain.go b/internal/api/grpc/instance/v2beta/domain.go index 380ebff5a..05bc06fe6 100644 --- a/internal/api/grpc/instance/v2beta/domain.go +++ b/internal/api/grpc/instance/v2beta/domain.go @@ -6,10 +6,16 @@ import ( "connectrpc.com/connect" "google.golang.org/protobuf/types/known/timestamppb" + instancev2 "github.com/zitadel/zitadel/backend/v3/api/instance/v2" + "github.com/zitadel/zitadel/internal/api/authz" instance "github.com/zitadel/zitadel/pkg/grpc/instance/v2beta" ) func (s *Server) AddCustomDomain(ctx context.Context, req *connect.Request[instance.AddCustomDomainRequest]) (*connect.Response[instance.AddCustomDomainResponse], error) { + if authz.GetFeatures(ctx).EnableRelationalTables { + return instancev2.AddCustomDomainBeta(ctx, req) + } + details, err := s.command.AddInstanceDomain(ctx, req.Msg.GetDomain()) if err != nil { return nil, err @@ -20,16 +26,30 @@ func (s *Server) AddCustomDomain(ctx context.Context, req *connect.Request[insta } func (s *Server) RemoveCustomDomain(ctx context.Context, req *connect.Request[instance.RemoveCustomDomainRequest]) (*connect.Response[instance.RemoveCustomDomainResponse], error) { - details, err := s.command.RemoveInstanceDomain(ctx, req.Msg.GetDomain()) + if authz.GetFeatures(ctx).EnableRelationalTables { + return instancev2.RemoveCustomDomainBeta(ctx, req) + } + + details, err := s.command.RemoveInstanceDomain(ctx, req.Msg.GetDomain(), false) if err != nil { return nil, err } + + var deletionDate *timestamppb.Timestamp + if details != nil { + deletionDate = timestamppb.New(details.EventDate) + } + return connect.NewResponse(&instance.RemoveCustomDomainResponse{ - DeletionDate: timestamppb.New(details.EventDate), + DeletionDate: deletionDate, }), nil } func (s *Server) AddTrustedDomain(ctx context.Context, req *connect.Request[instance.AddTrustedDomainRequest]) (*connect.Response[instance.AddTrustedDomainResponse], error) { + if authz.GetFeatures(ctx).EnableRelationalTables { + return instancev2.AddTrustedDomainBeta(ctx, req) + } + details, err := s.command.AddTrustedDomain(ctx, req.Msg.GetDomain()) if err != nil { return nil, err @@ -40,12 +60,20 @@ func (s *Server) AddTrustedDomain(ctx context.Context, req *connect.Request[inst } func (s *Server) RemoveTrustedDomain(ctx context.Context, req *connect.Request[instance.RemoveTrustedDomainRequest]) (*connect.Response[instance.RemoveTrustedDomainResponse], error) { - details, err := s.command.RemoveTrustedDomain(ctx, req.Msg.GetDomain()) + if authz.GetFeatures(ctx).EnableRelationalTables { + return instancev2.RemoveTrustedDomainBeta(ctx, req) + } + details, err := s.command.RemoveTrustedDomain(ctx, req.Msg.GetDomain(), false) if err != nil { return nil, err } + var deletionDate *timestamppb.Timestamp + if details != nil { + deletionDate = timestamppb.New(details.EventDate) + } + return connect.NewResponse(&instance.RemoveTrustedDomainResponse{ - DeletionDate: timestamppb.New(details.EventDate), + DeletionDate: deletionDate, }), nil } diff --git a/internal/api/grpc/instance/v2beta/instance.go b/internal/api/grpc/instance/v2beta/instance.go index a90876d1d..99824a3c9 100644 --- a/internal/api/grpc/instance/v2beta/instance.go +++ b/internal/api/grpc/instance/v2beta/instance.go @@ -13,23 +13,28 @@ import ( func (s *Server) DeleteInstance(ctx context.Context, request *connect.Request[instance.DeleteInstanceRequest]) (*connect.Response[instance.DeleteInstanceResponse], error) { if authz.GetFeatures(ctx).EnableRelationalTables { - return instancev2.DeleteInstance(ctx, request) + return instancev2.DeleteInstanceBeta(ctx, request) } - obj, err := s.command.RemoveInstance(ctx, request.Msg.GetInstanceId()) + obj, err := s.command.RemoveInstance(ctx, request.Msg.GetInstanceId(), false) if err != nil { return nil, err } + var deletionDate *timestamppb.Timestamp + if obj != nil { + deletionDate = timestamppb.New(obj.EventDate) + } + return connect.NewResponse(&instance.DeleteInstanceResponse{ - DeletionDate: timestamppb.New(obj.EventDate), + DeletionDate: deletionDate, }), nil } func (s *Server) UpdateInstance(ctx context.Context, request *connect.Request[instance.UpdateInstanceRequest]) (*connect.Response[instance.UpdateInstanceResponse], error) { if authz.GetFeatures(ctx).EnableRelationalTables { - return instancev2.UpdateInstance(ctx, request) + return instancev2.UpdateInstanceBeta(ctx, request) } obj, err := s.command.UpdateInstance(ctx, request.Msg.GetInstanceName()) diff --git a/internal/api/grpc/instance/v2beta/integration_test/domain_test.go b/internal/api/grpc/instance/v2beta/integration_test/domain_test.go index fa6b175ca..6370d1db1 100644 --- a/internal/api/grpc/instance/v2beta/integration_test/domain_test.go +++ b/internal/api/grpc/instance/v2beta/integration_test/domain_test.go @@ -4,16 +4,19 @@ package instance_test import ( "context" + "fmt" "strings" "testing" "time" + "github.com/muhlemmer/gu" "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/feature/v2" instance "github.com/zitadel/zitadel/pkg/grpc/instance/v2beta" ) @@ -27,79 +30,98 @@ func TestAddCustomDomain(t *testing.T) { ctxWithSysAuthZ := integration.WithSystemAuthorization(ctx) inst := integration.NewInstance(ctxWithSysAuthZ) - iamOwnerCtx := inst.WithAuthorization(context.Background(), integration.UserTypeIAMOwner) + iamOwnerCtx := inst.WithAuthorizationToken(context.Background(), integration.UserTypeIAMOwner) + + relationalInst := integration.NewInstance(ctxWithSysAuthZ) + iamOwnerRelationalCtx := relationalInst.WithAuthorizationToken(context.Background(), integration.UserTypeIAMOwner) + integration.EnsureInstanceFeature(t, ctxWithSysAuthZ, relationalInst, &feature.SetInstanceFeaturesRequest{EnableRelationalTables: gu.Ptr(true)}, func(tCollect *assert.CollectT, got *feature.GetInstanceFeaturesResponse) { + assert.True(tCollect, got.EnableRelationalTables.GetEnabled()) + }) t.Cleanup(func() { inst.Client.InstanceV2Beta.DeleteInstance(ctxWithSysAuthZ, &instance.DeleteInstanceRequest{InstanceId: inst.ID()}) + relationalInst.Client.InstanceV2Beta.DeleteInstance(ctxWithSysAuthZ, &instance.DeleteInstanceRequest{InstanceId: relationalInst.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(), - Domain: 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(), - Domain: 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(), - Domain: " ", - }, - 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(), - Domain: " " + integration.DomainName(), - }, - inputContext: ctxWithSysAuthZ, - }, + type instanceAndCtx struct { + testType string + instance *integration.Instance + ctx context.Context + } + testedInstances := []instanceAndCtx{ + {testType: "eventstore", instance: inst, ctx: iamOwnerCtx}, + {testType: "relational", instance: relationalInst, ctx: iamOwnerRelationalCtx}, } - for _, tc := range tt { - t.Run(tc.testName, func(t *testing.T) { - t.Cleanup(func() { + for _, instAndCtx := range testedInstances { + 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: instAndCtx.instance.ID(), + Domain: integration.DomainName(), + }, + inputContext: context.Background(), + expectedErrorCode: codes.Unauthenticated, + expectedErrorMsg: "auth header missing", + }, + { + testName: "when unauthZ context should return unauthZ error", + inputRequest: &instance.AddCustomDomainRequest{ + InstanceId: instAndCtx.instance.ID(), + Domain: integration.DomainName(), + }, + inputContext: instAndCtx.ctx, + expectedErrorCode: codes.PermissionDenied, + expectedErrorMsg: "No matching permissions found (AUTH-5mWD2)", + }, + { + testName: "when invalid domain should return invalid argument error", + inputRequest: &instance.AddCustomDomainRequest{ + InstanceId: instAndCtx.instance.ID(), + Domain: " ", + }, + inputContext: ctxWithSysAuthZ, + expectedErrorCode: codes.InvalidArgument, + expectedErrorMsg: "Errors.Invalid.Argument", + }, + { + testName: "when valid request should return successful response", + inputRequest: &instance.AddCustomDomainRequest{ + InstanceId: instAndCtx.instance.ID(), + Domain: " " + integration.DomainName(), + }, + inputContext: ctxWithSysAuthZ, + }, + } + + for _, tc := range tt { + t.Run(fmt.Sprintf("%s - %s", instAndCtx.testType, tc.testName), func(t *testing.T) { + t.Cleanup(func() { + if tc.expectedErrorMsg == "" { + instAndCtx.instance.Client.InstanceV2Beta.RemoveCustomDomain(ctxWithSysAuthZ, &instance.RemoveCustomDomainRequest{Domain: strings.TrimSpace(tc.inputRequest.Domain)}) + } + }) + + // Test + res, err := instAndCtx.instance.Client.InstanceV2Beta.AddCustomDomain(tc.inputContext, tc.inputRequest) + + // Verify + assert.Equal(t, tc.expectedErrorCode, status.Code(err)) + assert.Contains(t, status.Convert(err).Message(), tc.expectedErrorMsg) + if tc.expectedErrorMsg == "" { - inst.Client.InstanceV2Beta.RemoveCustomDomain(ctxWithSysAuthZ, &instance.RemoveCustomDomainRequest{Domain: strings.TrimSpace(tc.inputRequest.Domain)}) + assert.NotNil(t, res) + assert.NotEmpty(t, res.GetCreationDate()) } }) - - // Test - res, err := inst.Client.InstanceV2Beta.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()) - } - }) + } } } @@ -107,84 +129,108 @@ func TestRemoveCustomDomain(t *testing.T) { t.Parallel() // Given - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + ctx, cancel := context.WithTimeout(t.Context(), 15*time.Minute) defer cancel() ctxWithSysAuthZ := integration.WithSystemAuthorization(ctx) - inst := integration.NewInstance(ctxWithSysAuthZ) - iamOwnerCtx := inst.WithAuthorization(context.Background(), integration.UserTypeIAMOwner) + instES := integration.NewInstance(ctxWithSysAuthZ) + iamOwnerCtxES := instES.WithAuthorizationToken(t.Context(), integration.UserTypeIAMOwner) - customDomain := integration.DomainName() + instRelational := integration.NewInstance(ctxWithSysAuthZ) + iamOwnerCtxRelational := instRelational.WithAuthorizationToken(t.Context(), integration.UserTypeIAMOwner) + integration.EnsureInstanceFeature(t, ctxWithSysAuthZ, instRelational, &feature.SetInstanceFeaturesRequest{EnableRelationalTables: gu.Ptr(true)}, func(tCollect *assert.CollectT, got *feature.GetInstanceFeaturesResponse) { + assert.True(tCollect, got.EnableRelationalTables.GetEnabled()) + }) - _, err := inst.Client.InstanceV2Beta.AddCustomDomain(ctxWithSysAuthZ, &instance.AddCustomDomainRequest{InstanceId: inst.ID(), Domain: customDomain}) + customDomainES := integration.DomainName() + customDomainRelational := integration.DomainName() + + _, err := instES.Client.InstanceV2Beta.AddCustomDomain(ctxWithSysAuthZ, &instance.AddCustomDomainRequest{InstanceId: instES.ID(), Domain: customDomainES}) + require.Nil(t, err) + _, err = instRelational.Client.InstanceV2Beta.AddCustomDomain(ctxWithSysAuthZ, &instance.AddCustomDomainRequest{InstanceId: instRelational.ID(), Domain: customDomainRelational}) require.Nil(t, err) t.Cleanup(func() { - inst.Client.InstanceV2Beta.RemoveCustomDomain(ctxWithSysAuthZ, &instance.RemoveCustomDomainRequest{InstanceId: inst.ID(), Domain: customDomain}) - inst.Client.InstanceV2Beta.DeleteInstance(ctxWithSysAuthZ, &instance.DeleteInstanceRequest{InstanceId: inst.ID()}) + instES.Client.InstanceV2Beta.RemoveCustomDomain(ctxWithSysAuthZ, &instance.RemoveCustomDomainRequest{InstanceId: instES.ID(), Domain: customDomainES}) + instES.Client.InstanceV2Beta.DeleteInstance(ctxWithSysAuthZ, &instance.DeleteInstanceRequest{InstanceId: instES.ID()}) + instRelational.Client.InstanceV2Beta.RemoveCustomDomain(ctxWithSysAuthZ, &instance.RemoveCustomDomainRequest{InstanceId: instRelational.ID(), Domain: customDomainRelational}) + instRelational.Client.InstanceV2Beta.DeleteInstance(ctxWithSysAuthZ, &instance.DeleteInstanceRequest{InstanceId: instRelational.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(), - Domain: "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(), - Domain: "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(), - Domain: " ", - }, - 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(), - Domain: " " + customDomain, - }, - inputContext: ctxWithSysAuthZ, - }, + type instanceAndCtx struct { + testType string + instance *integration.Instance + ctx context.Context + customDomain string + } + testedInstances := []instanceAndCtx{ + {testType: "eventstore", instance: instES, ctx: iamOwnerCtxES, customDomain: customDomainES}, + {testType: "relational", instance: instRelational, ctx: iamOwnerCtxRelational, customDomain: customDomainRelational}, } - for _, tc := range tt { - t.Run(tc.testName, func(t *testing.T) { - // Test - res, err := inst.Client.InstanceV2Beta.RemoveCustomDomain(tc.inputContext, tc.inputRequest) + for _, instAndCtx := range testedInstances { + 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: instAndCtx.instance.ID(), + Domain: "custom1", + }, + inputContext: context.Background(), + expectedErrorCode: codes.Unauthenticated, + expectedErrorMsg: "auth header missing", + }, + { + testName: "when unauthZ context should return unauthZ error", + inputRequest: &instance.RemoveCustomDomainRequest{ + InstanceId: instAndCtx.instance.ID(), + Domain: "custom1", + }, + inputContext: instAndCtx.ctx, + expectedErrorCode: codes.PermissionDenied, + expectedErrorMsg: "No matching permissions found (AUTH-5mWD2)", + }, + { + testName: "when invalid domain should return invalid argument error", + inputRequest: &instance.RemoveCustomDomainRequest{ + InstanceId: instAndCtx.instance.ID(), + Domain: " ", + }, + inputContext: ctxWithSysAuthZ, + expectedErrorCode: codes.InvalidArgument, + expectedErrorMsg: "Errors.Invalid.Argument", + }, + { + testName: "when valid request should return successful response", + inputRequest: &instance.RemoveCustomDomainRequest{ + InstanceId: instAndCtx.instance.ID(), + Domain: " " + instAndCtx.customDomain, + }, + inputContext: ctxWithSysAuthZ, + }, + } - // Verify - assert.Equal(t, tc.expectedErrorCode, status.Code(err)) - assert.Equal(t, tc.expectedErrorMsg, status.Convert(err).Message()) + for _, tc := range tt { + t.Run(fmt.Sprintf("%s - %s", instAndCtx.testType, tc.testName), func(t *testing.T) { + // Test + res, err := instAndCtx.instance.Client.InstanceV2Beta.RemoveCustomDomain(tc.inputContext, tc.inputRequest) - if tc.expectedErrorMsg == "" { - assert.NotNil(t, res) - assert.NotEmpty(t, res.GetDeletionDate()) - } - }) + // Verify + assert.Equal(t, tc.expectedErrorCode, status.Code(err)) + assert.Contains(t, status.Convert(err).Message(), tc.expectedErrorMsg) + + if tc.expectedErrorMsg == "" { + assert.NotNil(t, res) + assert.NotEmpty(t, res.GetDeletionDate()) + } + }) + } } } @@ -196,80 +242,100 @@ func TestAddTrustedDomain(t *testing.T) { defer cancel() ctxWithSysAuthZ := integration.WithSystemAuthorization(ctx) - inst := integration.NewInstance(ctxWithSysAuthZ) - orgOwnerCtx := inst.WithAuthorization(context.Background(), integration.UserTypeOrgOwner) + + // Eventstore objects + instES := integration.NewInstance(ctxWithSysAuthZ) + orgOwnerCtxES := instES.WithAuthorization(context.Background(), integration.UserTypeOrgOwner) + d1ES := "trusted" + integration.DomainName() + + // Relational objects + instRelational := integration.NewInstance(ctxWithSysAuthZ) + integration.EnsureInstanceFeature(t, ctxWithSysAuthZ, instRelational, &feature.SetInstanceFeaturesRequest{EnableRelationalTables: gu.Ptr(true)}, func(tCollect *assert.CollectT, got *feature.GetInstanceFeaturesResponse) { + assert.True(tCollect, got.EnableRelationalTables.GetEnabled()) + }) + orgOwnerCtxRelational := instRelational.WithAuthorizationToken(context.Background(), integration.UserTypeOrgOwner) + d1Relational := "trusted" + integration.DomainName() t.Cleanup(func() { - inst.Client.InstanceV2Beta.DeleteInstance(ctxWithSysAuthZ, &instance.DeleteInstanceRequest{InstanceId: inst.ID()}) + instES.Client.InstanceV2Beta.DeleteInstance(ctxWithSysAuthZ, &instance.DeleteInstanceRequest{InstanceId: instES.ID()}) + instRelational.Client.InstanceV2Beta.DeleteInstance(ctxWithSysAuthZ, &instance.DeleteInstanceRequest{InstanceId: instRelational.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(), - Domain: "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(), - Domain: "trusted1", - }, - inputContext: orgOwnerCtx, - expectedErrorCode: codes.PermissionDenied, - expectedErrorMsg: "No matching permissions found (AUTH-5mWD2)", - }, - { - testName: "when invalid domain should return invalid argument error", - inputRequest: &instance.AddTrustedDomainRequest{ - InstanceId: inst.ID(), - Domain: " ", - }, - 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(), - Domain: " " + integration.DomainName(), - }, - inputContext: ctxWithSysAuthZ, - }, + type instAndCtx struct { + testType string + inst *integration.Instance + orgOwnerCtx context.Context + domain string } - for _, tc := range tt { - t.Run(tc.testName, func(t *testing.T) { - t.Cleanup(func() { + testedInstanceAndCtxs := []instAndCtx{ + {testType: "eventstore", inst: instES, orgOwnerCtx: orgOwnerCtxES, domain: d1ES}, + {testType: "relational", inst: instRelational, orgOwnerCtx: orgOwnerCtxRelational, domain: d1Relational}, + } + + for _, stateCase := range testedInstanceAndCtxs { + 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: stateCase.inst.ID(), + Domain: stateCase.domain, + }, + inputContext: context.Background(), + expectedErrorCode: codes.Unauthenticated, + expectedErrorMsg: "auth header missing", + }, + { + testName: "when unauthZ context should return unauthZ error", + inputRequest: &instance.AddTrustedDomainRequest{ + InstanceId: stateCase.inst.ID(), + Domain: stateCase.domain, + }, + inputContext: stateCase.orgOwnerCtx, + expectedErrorCode: codes.PermissionDenied, + expectedErrorMsg: "No matching permissions found (AUTH-5mWD2)", + }, + { + testName: "when invalid domain should return invalid argument error", + inputRequest: &instance.AddTrustedDomainRequest{ + InstanceId: stateCase.inst.ID(), + Domain: " ", + }, + inputContext: ctxWithSysAuthZ, + expectedErrorCode: codes.InvalidArgument, + expectedErrorMsg: "Errors.Invalid.Argument", + }, + { + testName: "when valid request should return successful response", + inputRequest: &instance.AddTrustedDomainRequest{ + InstanceId: stateCase.inst.ID(), + Domain: " " + stateCase.domain, + }, + inputContext: ctxWithSysAuthZ, + }, + } + + for _, tc := range tt { + t.Run(fmt.Sprintf("%s - %s", stateCase.testType, tc.testName), func(t *testing.T) { + // Test + res, err := stateCase.inst.Client.InstanceV2Beta.AddTrustedDomain(tc.inputContext, tc.inputRequest) + + // Verify + assert.Equal(t, tc.expectedErrorCode, status.Code(err)) + assert.Contains(t, status.Convert(err).Message(), tc.expectedErrorMsg) + if tc.expectedErrorMsg == "" { - inst.Client.InstanceV2Beta.RemoveTrustedDomain(ctxWithSysAuthZ, &instance.RemoveTrustedDomainRequest{Domain: strings.TrimSpace(tc.inputRequest.Domain)}) + assert.NotNil(t, res) + assert.NotEmpty(t, res.GetCreationDate()) } }) - - // Test - res, err := inst.Client.InstanceV2Beta.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()) - } - }) + } } } @@ -281,69 +347,95 @@ func TestRemoveTrustedDomain(t *testing.T) { defer cancel() ctxWithSysAuthZ := integration.WithSystemAuthorization(ctx) - inst := integration.NewInstance(ctxWithSysAuthZ) - orgOwnerCtx := inst.WithAuthorization(context.Background(), integration.UserTypeOrgOwner) + // Eventstore objects + instES := integration.NewInstance(ctxWithSysAuthZ) + orgOwnerCtxES := instES.WithAuthorizationToken(context.Background(), integration.UserTypeOrgOwner) + trustedDomainES := integration.DomainName() + _, err := instES.Client.InstanceV2Beta.AddTrustedDomain(ctxWithSysAuthZ, &instance.AddTrustedDomainRequest{InstanceId: instES.ID(), Domain: trustedDomainES}) + require.Nil(t, err) - trustedDomain := integration.DomainName() - - _, err := inst.Client.InstanceV2Beta.AddTrustedDomain(ctxWithSysAuthZ, &instance.AddTrustedDomainRequest{InstanceId: inst.ID(), Domain: trustedDomain}) + // Relational objects + instRelational := integration.NewInstance(ctxWithSysAuthZ) + integration.EnsureInstanceFeature(t, ctxWithSysAuthZ, instRelational, &feature.SetInstanceFeaturesRequest{EnableRelationalTables: gu.Ptr(true)}, func(tCollect *assert.CollectT, got *feature.GetInstanceFeaturesResponse) { + assert.True(tCollect, got.EnableRelationalTables.GetEnabled()) + }) + orgOwnerCtxRelational := instRelational.WithAuthorizationToken(context.Background(), integration.UserTypeOrgOwner) + trustedDomainRelational := integration.DomainName() + _, err = instRelational.Client.InstanceV2Beta.AddTrustedDomain(ctxWithSysAuthZ, &instance.AddTrustedDomainRequest{InstanceId: instRelational.ID(), Domain: trustedDomainRelational}) require.Nil(t, err) t.Cleanup(func() { - inst.Client.InstanceV2Beta.RemoveTrustedDomain(ctxWithSysAuthZ, &instance.RemoveTrustedDomainRequest{InstanceId: inst.ID(), Domain: trustedDomain}) - inst.Client.InstanceV2Beta.DeleteInstance(ctxWithSysAuthZ, &instance.DeleteInstanceRequest{InstanceId: inst.ID()}) + instES.Client.InstanceV2Beta.RemoveTrustedDomain(ctxWithSysAuthZ, &instance.RemoveTrustedDomainRequest{InstanceId: instES.ID(), Domain: trustedDomainES}) + instES.Client.InstanceV2Beta.DeleteInstance(ctxWithSysAuthZ, &instance.DeleteInstanceRequest{InstanceId: instES.ID()}) + + instRelational.Client.InstanceV2Beta.RemoveTrustedDomain(ctxWithSysAuthZ, &instance.RemoveTrustedDomainRequest{InstanceId: instRelational.ID(), Domain: trustedDomainRelational}) + instRelational.Client.InstanceV2Beta.DeleteInstance(ctxWithSysAuthZ, &instance.DeleteInstanceRequest{InstanceId: instRelational.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(), - Domain: "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(), - Domain: "trusted1", - }, - inputContext: orgOwnerCtx, - expectedErrorCode: codes.PermissionDenied, - expectedErrorMsg: "No matching permissions found (AUTH-5mWD2)", - }, - { - testName: "when valid request should return successful response", - inputRequest: &instance.RemoveTrustedDomainRequest{ - InstanceId: inst.ID(), - Domain: " " + trustedDomain, - }, - inputContext: ctxWithSysAuthZ, - }, + type instAndCtx struct { + testType string + inst *integration.Instance + orgOwnerCtx context.Context + domain string } - for _, tc := range tt { - t.Run(tc.testName, func(t *testing.T) { - // Test - res, err := inst.Client.InstanceV2Beta.RemoveTrustedDomain(tc.inputContext, tc.inputRequest) + testedInstanceAndCtxs := []instAndCtx{ + {testType: "eventstore", inst: instES, orgOwnerCtx: orgOwnerCtxES, domain: trustedDomainES}, + {testType: "relational", inst: instRelational, orgOwnerCtx: orgOwnerCtxRelational, domain: trustedDomainRelational}, + } - // Verify - assert.Equal(t, tc.expectedErrorCode, status.Code(err)) - assert.Equal(t, tc.expectedErrorMsg, status.Convert(err).Message()) + for _, stateCase := range testedInstanceAndCtxs { + 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: stateCase.inst.ID(), + Domain: "trusted1", + }, + inputContext: context.Background(), + expectedErrorCode: codes.Unauthenticated, + expectedErrorMsg: "auth header missing", + }, + { + testName: "when unauthZ context should return unauthZ error", + inputRequest: &instance.RemoveTrustedDomainRequest{ + InstanceId: stateCase.inst.ID(), + Domain: "trusted1", + }, + inputContext: stateCase.orgOwnerCtx, + expectedErrorCode: codes.PermissionDenied, + expectedErrorMsg: "No matching permissions found (AUTH-5mWD2)", + }, + { + testName: "when valid request should return successful response", + inputRequest: &instance.RemoveTrustedDomainRequest{ + InstanceId: stateCase.inst.ID(), + Domain: " " + stateCase.domain, + }, + inputContext: ctxWithSysAuthZ, + }, + } - if tc.expectedErrorMsg == "" { - require.NotNil(t, res) - require.NotEmpty(t, res.GetDeletionDate()) - } - }) + for _, tc := range tt { + t.Run(fmt.Sprintf("%s - %s", stateCase.testType, tc.testName), func(t *testing.T) { + // Test + res, err := stateCase.inst.Client.InstanceV2Beta.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/v2beta/integration_test/instance_test.go b/internal/api/grpc/instance/v2beta/integration_test/instance_test.go index 576d2f5f5..4e76eb7b1 100644 --- a/internal/api/grpc/instance/v2beta/integration_test/instance_test.go +++ b/internal/api/grpc/instance/v2beta/integration_test/instance_test.go @@ -35,9 +35,13 @@ func TestDeleteInstace(t *testing.T) { assert.True(tCollect, got.EnableRelationalTables.GetEnabled()) }) - instances := []*integration.Instance{ - instEventStore, - instRelational, + type instanceWithType struct { + instType string + instance *integration.Instance + } + instances := []instanceWithType{ + {instType: "eventstore", instance: instEventStore}, + {instType: "relational", instance: instRelational}, } t.Cleanup(func() { instRelational.Client.InstanceV2Beta.DeleteInstance(ctxWithSysAuthZ, &instance.DeleteInstanceRequest{InstanceId: instRelational.ID()}) @@ -46,12 +50,13 @@ func TestDeleteInstace(t *testing.T) { for _, inst := range instances { tt := []struct { - testName string - inputRequest *instance.DeleteInstanceRequest - inputContext context.Context - expectedErrorMsg string - expectedErrorCode codes.Code - expectedInstanceID string + testName string + inputRequest *instance.DeleteInstanceRequest + inputContext context.Context + expectedErrorMsg string + expectedErrorCode codes.Code + expectedInstanceID string + expectsNullTimestamp bool }{ { testName: "when invalid context should return unauthN error", @@ -63,34 +68,33 @@ func TestDeleteInstace(t *testing.T) { expectedErrorMsg: "auth header missing", }, { - testName: "when invalid input should return invalid argument error", + testName: "when instance not found should return no error", inputRequest: &instance.DeleteInstanceRequest{ - InstanceId: inst.ID() + "invalid", + InstanceId: inst.instance.ID() + "invalid", }, - inputContext: ctxWithSysAuthZ, - expectedErrorCode: codes.NotFound, - expectedErrorMsg: "not found", + inputContext: ctxWithSysAuthZ, + expectsNullTimestamp: true, }, { testName: "when delete succeeds should return deletion date", inputRequest: &instance.DeleteInstanceRequest{ - InstanceId: inst.ID(), + InstanceId: inst.instance.ID(), }, inputContext: ctxWithSysAuthZ, - expectedInstanceID: inst.ID(), + expectedInstanceID: inst.instance.ID(), }, } for _, tc := range tt { - t.Run(tc.testName, func(t *testing.T) { + t.Run(fmt.Sprintf("%s - %s", inst.instType, tc.testName), func(t *testing.T) { // Test - res, err := inst.Client.InstanceV2Beta.DeleteInstance(tc.inputContext, tc.inputRequest) + res, err := inst.instance.Client.InstanceV2Beta.DeleteInstance(tc.inputContext, tc.inputRequest) // Verify assert.Equal(t, tc.expectedErrorCode, status.Code(err)) assert.Contains(t, status.Convert(err).Message(), tc.expectedErrorMsg) if tc.expectedErrorMsg == "" { require.NotNil(t, res) - require.NotEmpty(t, res.GetDeletionDate()) + require.Equal(t, tc.expectsNullTimestamp, res.GetDeletionDate() == nil) } }) } diff --git a/internal/api/grpc/instance/v2beta/integration_test/query_test.go b/internal/api/grpc/instance/v2beta/integration_test/query_test.go index 5eded645d..f648a7ed9 100644 --- a/internal/api/grpc/instance/v2beta/integration_test/query_test.go +++ b/internal/api/grpc/instance/v2beta/integration_test/query_test.go @@ -232,97 +232,126 @@ func TestListCustomDomains(t *testing.T) { defer cancel() ctxWithSysAuthZ := integration.WithSystemAuthorization(ctx) - inst := integration.NewInstance(ctxWithSysAuthZ) - orgOwnerCtx := inst.WithAuthorization(context.Background(), integration.UserTypeOrgOwner) - d1, d2 := "custom."+integration.DomainName(), "custom."+integration.DomainName() - - _, err := inst.Client.InstanceV2Beta.AddCustomDomain(ctxWithSysAuthZ, &instance.AddCustomDomainRequest{InstanceId: inst.ID(), Domain: d1}) + // Eventstore objects + instES := integration.NewInstance(ctxWithSysAuthZ) + orgOwnerCtxES := instES.WithAuthorization(context.Background(), integration.UserTypeOrgOwner) + d1ES, d2ES := "custom."+integration.DomainName(), "custom."+integration.DomainName() + _, err := instES.Client.InstanceV2Beta.AddCustomDomain(ctxWithSysAuthZ, &instance.AddCustomDomainRequest{InstanceId: instES.ID(), Domain: d1ES}) require.Nil(t, err) - _, err = inst.Client.InstanceV2Beta.AddCustomDomain(ctxWithSysAuthZ, &instance.AddCustomDomainRequest{InstanceId: inst.ID(), Domain: d2}) + _, err = instES.Client.InstanceV2Beta.AddCustomDomain(ctxWithSysAuthZ, &instance.AddCustomDomainRequest{InstanceId: instES.ID(), Domain: d2ES}) + require.Nil(t, err) + + // Relational objects + instRelational := integration.NewInstance(ctxWithSysAuthZ) + integration.EnsureInstanceFeature(t, ctx, instRelational, &feature.SetInstanceFeaturesRequest{EnableRelationalTables: gu.Ptr(true)}, func(tCollect *assert.CollectT, got *feature.GetInstanceFeaturesResponse) { + assert.True(tCollect, got.EnableRelationalTables.GetEnabled()) + }) + orgOwnerCtxRelational := instRelational.WithAuthorization(context.Background(), integration.UserTypeOrgOwner) + d1Relational, d2Relational := "custom."+integration.DomainName(), "custom."+integration.DomainName() + _, err = instRelational.Client.InstanceV2Beta.AddCustomDomain(ctxWithSysAuthZ, &instance.AddCustomDomainRequest{InstanceId: instRelational.ID(), Domain: d1Relational}) + require.Nil(t, err) + _, err = instRelational.Client.InstanceV2Beta.AddCustomDomain(ctxWithSysAuthZ, &instance.AddCustomDomainRequest{InstanceId: instRelational.ID(), Domain: d2Relational}) require.Nil(t, err) t.Cleanup(func() { - inst.Client.InstanceV2Beta.RemoveCustomDomain(ctxWithSysAuthZ, &instance.RemoveCustomDomainRequest{InstanceId: inst.ID(), Domain: d1}) - inst.Client.InstanceV2Beta.RemoveCustomDomain(ctxWithSysAuthZ, &instance.RemoveCustomDomainRequest{InstanceId: inst.ID(), Domain: d2}) - inst.Client.InstanceV2Beta.DeleteInstance(ctxWithSysAuthZ, &instance.DeleteInstanceRequest{InstanceId: inst.ID()}) + instES.Client.InstanceV2Beta.RemoveCustomDomain(ctxWithSysAuthZ, &instance.RemoveCustomDomainRequest{InstanceId: instES.ID(), Domain: d1ES}) + instES.Client.InstanceV2Beta.RemoveCustomDomain(ctxWithSysAuthZ, &instance.RemoveCustomDomainRequest{InstanceId: instES.ID(), Domain: d2ES}) + instES.Client.InstanceV2Beta.DeleteInstance(ctxWithSysAuthZ, &instance.DeleteInstanceRequest{InstanceId: instES.ID()}) + + instRelational.Client.InstanceV2Beta.RemoveCustomDomain(ctxWithSysAuthZ, &instance.RemoveCustomDomainRequest{InstanceId: instRelational.ID(), Domain: d1Relational}) + instRelational.Client.InstanceV2Beta.RemoveCustomDomain(ctxWithSysAuthZ, &instance.RemoveCustomDomainRequest{InstanceId: instRelational.ID(), Domain: d2Relational}) + instRelational.Client.InstanceV2Beta.DeleteInstance(ctxWithSysAuthZ, &instance.DeleteInstanceRequest{InstanceId: instRelational.ID()}) }) - tt := []struct { - testName string - inputRequest *instance.ListCustomDomainsRequest - inputContext context.Context - expectedErrorMsg string - expectedErrorCode codes.Code - expectedDomains []string + testData := []struct { + testType string + inst *integration.Instance + orgOwnerCtx context.Context + 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, - Queries: []*instance.DomainSearchQuery{ - { - Query: &instance.DomainSearchQuery_DomainQuery{ - DomainQuery: &instance.DomainQuery{Domain: "custom", Method: object.TextQueryMethod_TEXT_QUERY_METHOD_CONTAINS}, - }, - }, - }, - }, - inputContext: orgOwnerCtx, - expectedErrorCode: codes.PermissionDenied, - expectedErrorMsg: "No matching permissions found (AUTH-5mWD2)", - }, - { - testName: "when valid request with filter should return paginated response", - inputRequest: &instance.ListCustomDomainsRequest{ - InstanceId: inst.ID(), - Pagination: &filter.PaginationRequest{Offset: 0, Limit: 10}, - SortingColumn: instance.DomainFieldName_DOMAIN_FIELD_NAME_CREATION_DATE, - Queries: []*instance.DomainSearchQuery{ - { - Query: &instance.DomainSearchQuery_DomainQuery{ - DomainQuery: &instance.DomainQuery{Domain: "custom", Method: object.TextQueryMethod_TEXT_QUERY_METHOD_CONTAINS}, - }, - }, - }, - }, - inputContext: ctxWithSysAuthZ, - expectedDomains: []string{d1, d2}, - }, + {testType: "eventstore", inst: instES, orgOwnerCtx: orgOwnerCtxES, expectedDomains: []string{d1ES, d2ES}}, + {testType: "relational", inst: instRelational, orgOwnerCtx: orgOwnerCtxRelational, expectedDomains: []string{d1Relational, d2Relational}}, } + for _, td := range testData { - 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.InstanceV2Beta.ListCustomDomains(tc.inputContext, tc.inputRequest) + 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: td.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: td.inst.ID(), + Pagination: &filter.PaginationRequest{Offset: 0, Limit: 10}, + SortingColumn: instance.DomainFieldName_DOMAIN_FIELD_NAME_CREATION_DATE, + Queries: []*instance.DomainSearchQuery{ + { + Query: &instance.DomainSearchQuery_DomainQuery{ + DomainQuery: &instance.DomainQuery{Domain: "custom", Method: object.TextQueryMethod_TEXT_QUERY_METHOD_CONTAINS}, + }, + }, + }, + }, + inputContext: td.orgOwnerCtx, + expectedErrorCode: codes.PermissionDenied, + expectedErrorMsg: "No matching permissions found (AUTH-5mWD2)", + }, + { + testName: "when valid request with filter should return paginated response", + inputRequest: &instance.ListCustomDomainsRequest{ + InstanceId: td.inst.ID(), + Pagination: &filter.PaginationRequest{Offset: 0, Limit: 10}, + SortingColumn: instance.DomainFieldName_DOMAIN_FIELD_NAME_CREATION_DATE, + Queries: []*instance.DomainSearchQuery{ + { + Query: &instance.DomainSearchQuery_DomainQuery{ + DomainQuery: &instance.DomainQuery{Domain: "custom", Method: object.TextQueryMethod_TEXT_QUERY_METHOD_CONTAINS}, + }, + }, + }, + }, + inputContext: ctxWithSysAuthZ, + expectedDomains: td.expectedDomains, + }, + } - // Verify - assert.Equal(collect, tc.expectedErrorCode, status.Code(err)) - assert.Equal(collect, tc.expectedErrorMsg, status.Convert(err).Message()) + for _, tc := range tt { + t.Run(fmt.Sprintf("%s - %s", td.testType, tc.testName), func(t *testing.T) { + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(tc.inputContext, time.Minute) + require.EventuallyWithT(t, func(collect *assert.CollectT) { + // Test + res, err := td.inst.Client.InstanceV2Beta.ListCustomDomains(tc.inputContext, tc.inputRequest) - if tc.expectedErrorMsg == "" { - domains := []string{} - for _, d := range res.GetDomains() { - domains = append(domains, d.GetDomain()) + // 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) } - - assert.Subset(collect, domains, tc.expectedDomains) - } - }, retryDuration, tick) - }) + }, retryDuration, tick) + }) + } } } @@ -334,91 +363,118 @@ func TestListTrustedDomains(t *testing.T) { defer cancel() ctxWithSysAuthZ := integration.WithSystemAuthorization(ctx) - inst := integration.NewInstance(ctxWithSysAuthZ) - orgOwnerCtx := inst.WithAuthorization(context.Background(), integration.UserTypeOrgOwner) - d1, d2 := "trusted."+integration.DomainName(), "trusted."+integration.DomainName() - - _, err := inst.Client.InstanceV2Beta.AddTrustedDomain(ctxWithSysAuthZ, &instance.AddTrustedDomainRequest{InstanceId: inst.ID(), Domain: d1}) + // Eventstore objects + instES := integration.NewInstance(ctxWithSysAuthZ) + orgOwnerCtxES := instES.WithAuthorization(context.Background(), integration.UserTypeOrgOwner) + d1ES, d2ES := "trusted."+integration.DomainName(), "trusted."+integration.DomainName() + _, err := instES.Client.InstanceV2Beta.AddTrustedDomain(ctxWithSysAuthZ, &instance.AddTrustedDomainRequest{InstanceId: instES.ID(), Domain: d1ES}) require.Nil(t, err) - _, err = inst.Client.InstanceV2Beta.AddTrustedDomain(ctxWithSysAuthZ, &instance.AddTrustedDomainRequest{InstanceId: inst.ID(), Domain: d2}) + _, err = instES.Client.InstanceV2Beta.AddTrustedDomain(ctxWithSysAuthZ, &instance.AddTrustedDomainRequest{InstanceId: instES.ID(), Domain: d2ES}) + require.Nil(t, err) + + // Relational objects + instRelational := integration.NewInstance(ctxWithSysAuthZ) + integration.EnsureInstanceFeature(t, ctx, instRelational, &feature.SetInstanceFeaturesRequest{EnableRelationalTables: gu.Ptr(true)}, func(tCollect *assert.CollectT, got *feature.GetInstanceFeaturesResponse) { + assert.True(tCollect, got.EnableRelationalTables.GetEnabled()) + }) + orgOwnerCtxRelational := instRelational.WithAuthorization(context.Background(), integration.UserTypeOrgOwner) + d1Relational, d2Relational := "trusted."+integration.DomainName(), "trusted."+integration.DomainName() + _, err = instRelational.Client.InstanceV2Beta.AddTrustedDomain(ctxWithSysAuthZ, &instance.AddTrustedDomainRequest{InstanceId: instRelational.ID(), Domain: d1Relational}) + require.Nil(t, err) + _, err = instRelational.Client.InstanceV2Beta.AddTrustedDomain(ctxWithSysAuthZ, &instance.AddTrustedDomainRequest{InstanceId: instRelational.ID(), Domain: d2Relational}) require.Nil(t, err) t.Cleanup(func() { - inst.Client.InstanceV2Beta.RemoveTrustedDomain(ctxWithSysAuthZ, &instance.RemoveTrustedDomainRequest{InstanceId: inst.ID(), Domain: d1}) - inst.Client.InstanceV2Beta.RemoveTrustedDomain(ctxWithSysAuthZ, &instance.RemoveTrustedDomainRequest{InstanceId: inst.ID(), Domain: d2}) - inst.Client.InstanceV2Beta.DeleteInstance(ctxWithSysAuthZ, &instance.DeleteInstanceRequest{InstanceId: inst.ID()}) + instES.Client.InstanceV2Beta.RemoveTrustedDomain(ctxWithSysAuthZ, &instance.RemoveTrustedDomainRequest{InstanceId: instES.ID(), Domain: d1ES}) + instES.Client.InstanceV2Beta.RemoveTrustedDomain(ctxWithSysAuthZ, &instance.RemoveTrustedDomainRequest{InstanceId: instES.ID(), Domain: d2ES}) + instES.Client.InstanceV2Beta.DeleteInstance(ctxWithSysAuthZ, &instance.DeleteInstanceRequest{InstanceId: instES.ID()}) + + instRelational.Client.InstanceV2Beta.RemoveTrustedDomain(ctxWithSysAuthZ, &instance.RemoveTrustedDomainRequest{InstanceId: instRelational.ID(), Domain: d1Relational}) + instRelational.Client.InstanceV2Beta.RemoveTrustedDomain(ctxWithSysAuthZ, &instance.RemoveTrustedDomainRequest{InstanceId: instRelational.ID(), Domain: d2Relational}) + instRelational.Client.InstanceV2Beta.DeleteInstance(ctxWithSysAuthZ, &instance.DeleteInstanceRequest{InstanceId: instRelational.ID()}) }) - tt := []struct { - testName string - inputRequest *instance.ListTrustedDomainsRequest - inputContext context.Context - expectedErrorMsg string - expectedErrorCode codes.Code - expectedDomains []string + testData := []struct { + testType string + inst *integration.Instance + orgOwnerCtx context.Context + expectedDomains []string }{ - { - testName: "when invalid context should return unauthN error", - inputRequest: &instance.ListTrustedDomainsRequest{ - InstanceId: inst.ID(), - Pagination: &filter.PaginationRequest{Offset: 0, Limit: 10}, + {testType: "eventstore", inst: instES, orgOwnerCtx: orgOwnerCtxES, expectedDomains: []string{d1ES, d2ES}}, + {testType: "relational", inst: instRelational, orgOwnerCtx: orgOwnerCtxRelational, expectedDomains: []string{d1Relational, d2Relational}}, + } + for _, td := range testData { + 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: td.inst.ID(), + Pagination: &filter.PaginationRequest{Offset: 0, Limit: 10}, + }, + inputContext: context.Background(), + expectedErrorCode: codes.Unauthenticated, + expectedErrorMsg: "auth header missing", }, - 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}, + { + testName: "when unauthZ context should return unauthZ error", + inputRequest: &instance.ListTrustedDomainsRequest{ + InstanceId: td.inst.ID(), + Pagination: &filter.PaginationRequest{Offset: 0, Limit: 10}, + }, + inputContext: td.orgOwnerCtx, + expectedErrorCode: codes.PermissionDenied, + expectedErrorMsg: "No matching permissions found (AUTH-5mWD2)", }, - inputContext: orgOwnerCtx, - expectedErrorCode: codes.PermissionDenied, - expectedErrorMsg: "No matching permissions found (AUTH-5mWD2)", - }, - { - testName: "when valid request with filter should return paginated response", - inputRequest: &instance.ListTrustedDomainsRequest{ - InstanceId: inst.ID(), - Pagination: &filter.PaginationRequest{Offset: 0, Limit: 10}, - SortingColumn: instance.TrustedDomainFieldName_TRUSTED_DOMAIN_FIELD_NAME_CREATION_DATE, - Queries: []*instance.TrustedDomainSearchQuery{ - { - Query: &instance.TrustedDomainSearchQuery_DomainQuery{ - DomainQuery: &instance.DomainQuery{Domain: "trusted", Method: object.TextQueryMethod_TEXT_QUERY_METHOD_CONTAINS}, + { + testName: "when valid request with filter should return paginated response", + inputRequest: &instance.ListTrustedDomainsRequest{ + InstanceId: td.inst.ID(), + Pagination: &filter.PaginationRequest{Offset: 0, Limit: 10}, + SortingColumn: instance.TrustedDomainFieldName_TRUSTED_DOMAIN_FIELD_NAME_CREATION_DATE, + Queries: []*instance.TrustedDomainSearchQuery{ + { + Query: &instance.TrustedDomainSearchQuery_DomainQuery{ + DomainQuery: &instance.DomainQuery{Domain: "trusted", Method: object.TextQueryMethod_TEXT_QUERY_METHOD_CONTAINS}, + }, }, }, }, + inputContext: ctxWithSysAuthZ, + expectedDomains: td.expectedDomains, }, - inputContext: ctxWithSysAuthZ, - expectedDomains: []string{d1, d2}, - }, - } + } - 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.InstanceV2Beta.ListTrustedDomains(tc.inputContext, tc.inputRequest) + for _, tc := range tt { + t.Run(fmt.Sprintf("%s - %s", td.testType, tc.testName), func(t *testing.T) { + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(tc.inputContext, time.Minute) + require.EventuallyWithT(t, func(collect *assert.CollectT) { + // Test + res, err := td.inst.Client.InstanceV2Beta.ListTrustedDomains(tc.inputContext, tc.inputRequest) - // Verify - assert.Equal(collect, tc.expectedErrorCode, status.Code(err)) - assert.Equal(collect, tc.expectedErrorMsg, status.Convert(err).Message()) + // 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) + if tc.expectedErrorMsg == "" { + require.NotNil(t, res) - domains := []string{} - for _, d := range res.GetTrustedDomain() { - domains = append(domains, d.GetDomain()) + domains := []string{} + for _, d := range res.GetTrustedDomain() { + domains = append(domains, d.GetDomain()) + } + + assert.Subset(collect, domains, tc.expectedDomains) } - - assert.Subset(collect, domains, tc.expectedDomains) - } - }, retryDuration, tick) - }) + }, retryDuration, tick) + }) + } } } diff --git a/internal/api/grpc/instance/v2beta/query.go b/internal/api/grpc/instance/v2beta/query.go index bda2ea46c..ca7dc4708 100644 --- a/internal/api/grpc/instance/v2beta/query.go +++ b/internal/api/grpc/instance/v2beta/query.go @@ -13,7 +13,7 @@ import ( func (s *Server) GetInstance(ctx context.Context, request *connect.Request[instance.GetInstanceRequest]) (*connect.Response[instance.GetInstanceResponse], error) { if authz.GetFeatures(ctx).EnableRelationalTables { - return instancev2.GetInstance(ctx, request) + return instancev2.GetInstanceBeta(ctx, request) } inst, err := s.query.Instance(ctx, true) @@ -28,7 +28,7 @@ func (s *Server) GetInstance(ctx context.Context, request *connect.Request[insta func (s *Server) ListInstances(ctx context.Context, req *connect.Request[instance.ListInstancesRequest]) (*connect.Response[instance.ListInstancesResponse], error) { if authz.GetFeatures(ctx).EnableRelationalTables { - return instancev2.ListInstances(ctx, req) + return instancev2.ListInstancesBeta(ctx, req) } queries, err := ListInstancesRequestToModel(req.Msg, s.systemDefaults) @@ -48,6 +48,10 @@ func (s *Server) ListInstances(ctx context.Context, req *connect.Request[instanc } func (s *Server) ListCustomDomains(ctx context.Context, req *connect.Request[instance.ListCustomDomainsRequest]) (*connect.Response[instance.ListCustomDomainsResponse], error) { + if authz.GetFeatures(ctx).EnableRelationalTables { + return instancev2.ListCustomDomainsBeta(ctx, req) + } + queries, err := ListCustomDomainsRequestToModel(req.Msg, s.systemDefaults) if err != nil { return nil, err @@ -65,6 +69,10 @@ func (s *Server) ListCustomDomains(ctx context.Context, req *connect.Request[ins } func (s *Server) ListTrustedDomains(ctx context.Context, req *connect.Request[instance.ListTrustedDomainsRequest]) (*connect.Response[instance.ListTrustedDomainsResponse], error) { + if authz.GetFeatures(ctx).EnableRelationalTables { + return instancev2.ListTrustedDomainsBeta(ctx, req) + } + queries, err := ListTrustedDomainsRequestToModel(req.Msg, s.systemDefaults) if err != nil { return nil, err diff --git a/internal/api/grpc/org/v2beta/org.go b/internal/api/grpc/org/v2beta/org.go index 29a522309..9c1122d4e 100644 --- a/internal/api/grpc/org/v2beta/org.go +++ b/internal/api/grpc/org/v2beta/org.go @@ -34,7 +34,7 @@ func (s *Server) CreateOrganization(ctx context.Context, request *connect.Reques func (s *Server) UpdateOrganization(ctx context.Context, request *connect.Request[v2beta_org.UpdateOrganizationRequest]) (*connect.Response[v2beta_org.UpdateOrganizationResponse], error) { if authz.GetFeatures(ctx).EnableRelationalTables { - return orgv2.UpdateOrganization(ctx, request) + return orgv2.UpdateOrganizationBeta(ctx, request) } org, err := s.command.ChangeOrg(ctx, request.Msg.GetId(), request.Msg.GetName()) @@ -71,7 +71,7 @@ func (s *Server) ListOrganizations(ctx context.Context, request *connect.Request func (s *Server) DeleteOrganization(ctx context.Context, request *connect.Request[v2beta_org.DeleteOrganizationRequest]) (*connect.Response[v2beta_org.DeleteOrganizationResponse], error) { if authz.GetFeatures(ctx).EnableRelationalTables { - return orgv2.DeleteOrganization(ctx, request) + return orgv2.DeleteOrganizationBeta(ctx, request) } details, err := s.command.RemoveOrg(ctx, request.Msg.GetId()) @@ -127,7 +127,7 @@ func (s *Server) DeleteOrganizationMetadata(ctx context.Context, request *connec func (s *Server) DeactivateOrganization(ctx context.Context, request *connect.Request[org.DeactivateOrganizationRequest]) (*connect.Response[org.DeactivateOrganizationResponse], error) { if authz.GetFeatures(ctx).EnableRelationalTables { - return orgv2.DeactivateOrganization(ctx, request) + return orgv2.DeactivateOrganizationBeta(ctx, request) } objectDetails, err := s.command.DeactivateOrg(ctx, request.Msg.GetId()) @@ -141,7 +141,7 @@ func (s *Server) DeactivateOrganization(ctx context.Context, request *connect.Re func (s *Server) ActivateOrganization(ctx context.Context, request *connect.Request[org.ActivateOrganizationRequest]) (*connect.Response[org.ActivateOrganizationResponse], error) { if authz.GetFeatures(ctx).EnableRelationalTables { - return orgv2.ActivateOrganization(ctx, request) + return orgv2.ActivateOrganizationBeta(ctx, request) } objectDetails, err := s.command.ReactivateOrg(ctx, request.Msg.GetId()) diff --git a/internal/api/grpc/system/instance.go b/internal/api/grpc/system/instance.go index ccfcfecbf..f4cabf8e5 100644 --- a/internal/api/grpc/system/instance.go +++ b/internal/api/grpc/system/instance.go @@ -83,7 +83,7 @@ func (s *Server) CreateInstance(ctx context.Context, req *system_pb.CreateInstan } func (s *Server) RemoveInstance(ctx context.Context, req *system_pb.RemoveInstanceRequest) (*system_pb.RemoveInstanceResponse, error) { - details, err := s.command.RemoveInstance(ctx, req.InstanceId) + details, err := s.command.RemoveInstance(ctx, req.InstanceId, true) if err != nil { return nil, err } @@ -160,7 +160,7 @@ func (s *Server) AddDomain(ctx context.Context, req *system_pb.AddDomainRequest) } func (s *Server) RemoveDomain(ctx context.Context, req *system_pb.RemoveDomainRequest) (*system_pb.RemoveDomainResponse, error) { - details, err := s.command.RemoveInstanceDomain(ctx, req.Domain) + details, err := s.command.RemoveInstanceDomain(ctx, req.Domain, true) if err != nil { return nil, err } diff --git a/internal/command/instance.go b/internal/command/instance.go index 3841fa67d..5a9945a2c 100644 --- a/internal/command/instance.go +++ b/internal/command/instance.go @@ -894,17 +894,6 @@ func (c *Commands) prepareSetDefaultOrg(a *instance.Aggregate, orgID string) pre } } -func (c *Commands) setIAMProject(ctx context.Context, iamAgg *eventstore.Aggregate, iamWriteModel *InstanceWriteModel, projectID string) (eventstore.Command, error) { - err := c.eventstore.FilterToQueryReducer(ctx, iamWriteModel) - if err != nil { - return nil, err - } - if iamWriteModel.ProjectID != "" { - return nil, zerrors.ThrowPreconditionFailed(nil, "IAM-EGbw2", "Errors.IAM.IAMProjectAlreadySet") - } - return instance.NewIAMProjectSetEvent(ctx, iamAgg, projectID), 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 { @@ -959,54 +948,42 @@ func getSystemConfigWriteModel(ctx context.Context, filter preparation.FilterToQ return writeModel, err } -func (c *Commands) RemoveInstance(ctx context.Context, id string) (*domain.ObjectDetails, error) { +func (c *Commands) RemoveInstance(ctx context.Context, id string, errorIfNotFound bool) (*domain.ObjectDetails, error) { instID := strings.TrimSpace(id) if instID == "" || len(instID) > 200 { return nil, zerrors.ThrowInvalidArgument(nil, "COMMA-VeS2zI", "Errors.Invalid.Argument") } instanceAgg := instance.NewAggregate(instID) - cmds, err := preparation.PrepareCommands(ctx, c.eventstore.Filter, c.prepareRemoveInstance(instanceAgg)) + writeModel, err := c.getInstanceWriteModelByID(ctx, instanceAgg.ID) if err != nil { return nil, err } + if !writeModel.State.Exists() { + if errorIfNotFound { + return nil, zerrors.ThrowNotFound(err, "COMMA-AE3GS", "Errors.Instance.NotFound") + } + return nil, nil + } + + milestoneAggregate := milestone.NewInstanceAggregate(instanceAgg.ID) + cmds := []eventstore.Command{ + instance.NewInstanceRemovedEvent(ctx, + &instanceAgg.Aggregate, + writeModel.Name, + writeModel.Domains), + milestone.NewReachedEvent(ctx, + milestoneAggregate, + milestone.InstanceDeleted), + } + + if err := c.pushAppendAndReduce(ctx, writeModel, cmds...); err != nil { + return nil, err + } - events, err := c.eventstore.Push(ctx, cmds...) - if err != nil { - return nil, err - } err = c.caches.milestones.Invalidate(ctx, milestoneIndexInstanceID, id) logging.OnError(err).Error("milestone invalidate") - return &domain.ObjectDetails{ - Sequence: events[len(events)-1].Sequence(), - EventDate: events[len(events)-1].CreatedAt(), - ResourceOwner: events[len(events)-1].Aggregate().InstanceID, - }, nil -} - -func (c *Commands) prepareRemoveInstance(a *instance.Aggregate) preparation.Validation { - return func() (preparation.CreateCommands, error) { - return func(ctx context.Context, filter preparation.FilterToQueryReducer) ([]eventstore.Command, error) { - writeModel, err := c.getInstanceWriteModelByID(ctx, a.ID) - if err != nil { - return nil, zerrors.ThrowNotFound(err, "COMMA-pax9m3", "Errors.Instance.NotFound") - } - if !writeModel.State.Exists() { - return nil, zerrors.ThrowNotFound(err, "COMMA-AE3GS", "Errors.Instance.NotFound") - } - milestoneAggregate := milestone.NewInstanceAggregate(a.ID) - return []eventstore.Command{ - instance.NewInstanceRemovedEvent(ctx, - &a.Aggregate, - writeModel.Name, - writeModel.Domains), - milestone.NewReachedEvent(ctx, - milestoneAggregate, - milestone.InstanceDeleted), - }, - nil - }, nil - } + return writeModelToObjectDetails(&writeModel.WriteModel), nil } func (c *Commands) getInstanceWriteModelByID(ctx context.Context, orgID string) (*InstanceWriteModel, error) { diff --git a/internal/command/instance_domain.go b/internal/command/instance_domain.go index 8eacee359..d2efc01f0 100644 --- a/internal/command/instance_domain.go +++ b/internal/command/instance_domain.go @@ -17,7 +17,7 @@ import ( ) var ( - allowDomainRunes = regexp.MustCompile("^[a-zA-Z0-9\\.\\-]+$") + allowDomainRunes = regexp.MustCompile(`^[a-zA-Z0-9\.\-]+$`) ) func (c *Commands) AddInstanceDomain(ctx context.Context, instanceDomain string) (*domain.ObjectDetails, error) { @@ -56,22 +56,35 @@ func (c *Commands) SetPrimaryInstanceDomain(ctx context.Context, instanceDomain }, nil } -func (c *Commands) RemoveInstanceDomain(ctx context.Context, instanceDomain string) (*domain.ObjectDetails, error) { +func (c *Commands) RemoveInstanceDomain(ctx context.Context, instanceDomain string, errorIfNotFound bool) (*domain.ObjectDetails, error) { instanceAgg := instance.NewAggregate(authz.GetInstance(ctx).InstanceID()) - validation := removeInstanceDomain(instanceAgg, instanceDomain) - cmds, err := preparation.PrepareCommands(ctx, c.eventstore.Filter, validation) + + if instanceDomain = strings.TrimSpace(instanceDomain); instanceDomain == "" { + return nil, zerrors.ThrowInvalidArgument(nil, "INST-39nls", "Errors.Invalid.Argument") + } + + //nolint: staticcheck + writeModel, err := getInstanceDomainWriteModel(ctx, c.eventstore.Filter, instanceDomain) if err != nil { return nil, err } - events, err := c.eventstore.Push(ctx, cmds...) - if err != nil { + + if writeModel.State != domain.InstanceDomainStateActive { + if errorIfNotFound { + return nil, zerrors.ThrowNotFound(nil, "INSTANCE-8ls9f", "Errors.Instance.Domain.NotFound") + } + return nil, nil + } + if writeModel.Generated { + return nil, zerrors.ThrowPreconditionFailed(nil, "INSTANCE-9hn3n", "Errors.Instance.Domain.GeneratedNotRemovable") + } + cmds := []eventstore.Command{instance.NewDomainRemovedEvent(ctx, &instanceAgg.Aggregate, instanceDomain)} + + if err := c.pushAppendAndReduce(ctx, writeModel, cmds...); err != nil { return nil, err } - return &domain.ObjectDetails{ - Sequence: events[len(events)-1].Sequence(), - EventDate: events[len(events)-1].CreatedAt(), - ResourceOwner: events[len(events)-1].Aggregate().InstanceID, - }, nil + + return writeModelToObjectDetails(&writeModel.WriteModel), nil } func (c *Commands) addGeneratedInstanceDomain(ctx context.Context, a *instance.Aggregate, instanceName string) ([]preparation.Validation, error) { @@ -182,27 +195,6 @@ func setPrimaryInstanceDomain(a *instance.Aggregate, instanceDomain string) prep } } -func removeInstanceDomain(a *instance.Aggregate, instanceDomain string) preparation.Validation { - return func() (preparation.CreateCommands, error) { - if instanceDomain = strings.TrimSpace(instanceDomain); instanceDomain == "" { - return nil, zerrors.ThrowInvalidArgument(nil, "INST-39nls", "Errors.Invalid.Argument") - } - return func(ctx context.Context, filter preparation.FilterToQueryReducer) ([]eventstore.Command, error) { - domainWriteModel, err := getInstanceDomainWriteModel(ctx, filter, instanceDomain) - if err != nil { - return nil, err - } - if domainWriteModel.State != domain.InstanceDomainStateActive { - return nil, zerrors.ThrowNotFound(nil, "INSTANCE-8ls9f", "Errors.Instance.Domain.NotFound") - } - if domainWriteModel.Generated { - return nil, zerrors.ThrowPreconditionFailed(nil, "INSTANCE-9hn3n", "Errors.Instance.Domain.GeneratedNotRemovable") - } - return []eventstore.Command{instance.NewDomainRemovedEvent(ctx, &a.Aggregate, instanceDomain)}, nil - }, nil - } -} - func getInstanceDomainWriteModel(ctx context.Context, filter preparation.FilterToQueryReducer, domain string) (*InstanceDomainWriteModel, error) { domainWriteModel := NewInstanceDomainWriteModel(ctx, domain) events, err := filter(ctx, domainWriteModel.Query()) diff --git a/internal/command/instance_domain_test.go b/internal/command/instance_domain_test.go index ff56505ab..18a74acf9 100644 --- a/internal/command/instance_domain_test.go +++ b/internal/command/instance_domain_test.go @@ -313,8 +313,9 @@ func TestCommandSide_RemoveInstanceDomain(t *testing.T) { eventstore *eventstore.Eventstore } type args struct { - ctx context.Context - domain string + ctx context.Context + domain string + errorIfNotFound bool } type res struct { want *domain.ObjectDetails @@ -342,7 +343,7 @@ func TestCommandSide_RemoveInstanceDomain(t *testing.T) { }, }, { - name: "domain not exists, precondition error", + name: "domain not exists and errorIfNotFound set to TRUE should return not found error", fields: fields{ eventstore: eventstoreExpect( t, @@ -350,13 +351,29 @@ func TestCommandSide_RemoveInstanceDomain(t *testing.T) { ), }, args: args{ - ctx: context.Background(), - domain: "domain.ch", + ctx: context.Background(), + domain: "domain.ch", + errorIfNotFound: true, }, res: res{ err: zerrors.IsNotFound, }, }, + { + name: "domain not exists and errorIfNotFound set to FALSE should return no error", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter(), + ), + }, + args: args{ + ctx: context.Background(), + domain: "domain.ch", + errorIfNotFound: false, + }, + res: res{}, + }, { name: "remove domain, ok", fields: fields{ @@ -420,7 +437,7 @@ func TestCommandSide_RemoveInstanceDomain(t *testing.T) { r := &Commands{ eventstore: tt.fields.eventstore, } - got, err := r.RemoveInstanceDomain(tt.args.ctx, tt.args.domain) + got, err := r.RemoveInstanceDomain(tt.args.ctx, tt.args.domain, tt.args.errorIfNotFound) if tt.res.err == nil { assert.NoError(t, err) } diff --git a/internal/command/instance_test.go b/internal/command/instance_test.go index 933d9ccfd..d77612690 100644 --- a/internal/command/instance_test.go +++ b/internal/command/instance_test.go @@ -1566,8 +1566,9 @@ func TestCommandSide_RemoveInstance(t *testing.T) { eventstore func(t *testing.T) *eventstore.Eventstore } type args struct { - ctx context.Context - instanceID string + ctx context.Context + instanceID string + errorIfNotFound bool } type res struct { want *domain.ObjectDetails @@ -1613,13 +1614,28 @@ func TestCommandSide_RemoveInstance(t *testing.T) { ), }, args: args{ - ctx: authz.WithInstanceID(context.Background(), "INSTANCE"), - instanceID: "INSTANCE", + ctx: authz.WithInstanceID(context.Background(), "INSTANCE"), + instanceID: "INSTANCE", + errorIfNotFound: true, }, res: res{ err: zerrors.IsNotFound, }, }, + { + name: "instance not existing, no error", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + ), + }, + args: args{ + ctx: authz.WithInstanceID(context.Background(), "INSTANCE"), + instanceID: "INSTANCE", + errorIfNotFound: false, + }, + res: res{}, + }, { name: "instance removed, not found error", fields: fields{ @@ -1643,13 +1659,43 @@ func TestCommandSide_RemoveInstance(t *testing.T) { ), }, args: args{ - ctx: authz.WithInstanceID(context.Background(), "INSTANCE"), - instanceID: "INSTANCE", + ctx: authz.WithInstanceID(context.Background(), "INSTANCE"), + instanceID: "INSTANCE", + errorIfNotFound: true, }, res: res{ err: zerrors.IsNotFound, }, }, + { + name: "instance removed, no error", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + instance.NewInstanceAddedEvent( + context.Background(), + &instance.NewAggregate("INSTANCE").Aggregate, + "INSTANCE", + ), + ), + eventFromEventPusher( + instance.NewInstanceRemovedEvent(context.Background(), + &instance.NewAggregate("INSTANCE").Aggregate, + "INSTANCE", + nil, + ), + ), + ), + ), + }, + args: args{ + ctx: authz.WithInstanceID(context.Background(), "INSTANCE"), + instanceID: "INSTANCE", + errorIfNotFound: false, + }, + res: res{}, + }, { name: "instance remove, ok", fields: fields{ @@ -1714,7 +1760,7 @@ func TestCommandSide_RemoveInstance(t *testing.T) { milestones: noop.NewCache[milestoneIndex, string, *MilestonesReached](), }, } - got, err := r.RemoveInstance(tt.args.ctx, tt.args.instanceID) + got, err := r.RemoveInstance(tt.args.ctx, tt.args.instanceID, tt.args.errorIfNotFound) if tt.res.err == nil { assert.NoError(t, err) } diff --git a/internal/command/instance_trusted_domain.go b/internal/command/instance_trusted_domain.go index 7d3e6abeb..b1e2ed739 100644 --- a/internal/command/instance_trusted_domain.go +++ b/internal/command/instance_trusted_domain.go @@ -33,7 +33,7 @@ func (c *Commands) AddTrustedDomain(ctx context.Context, trustedDomain string) ( return writeModelToObjectDetails(&model.WriteModel), nil } -func (c *Commands) RemoveTrustedDomain(ctx context.Context, trustedDomain string) (*domain.ObjectDetails, error) { +func (c *Commands) RemoveTrustedDomain(ctx context.Context, trustedDomain string, errorIfNotFound bool) (*domain.ObjectDetails, error) { trustedDomain = strings.TrimSpace(trustedDomain) if trustedDomain == "" || len(trustedDomain) > 253 { return nil, zerrors.ThrowInvalidArgument(nil, "COMMA-ajAzwu", "Errors.Invalid.Argument") @@ -48,8 +48,12 @@ func (c *Commands) RemoveTrustedDomain(ctx context.Context, trustedDomain string return nil, err } if !slices.Contains(model.Domains, trustedDomain) { - return nil, zerrors.ThrowNotFound(nil, "COMMA-de3z9", "Errors.Instance.Domain.NotFound") + if errorIfNotFound { + return nil, zerrors.ThrowNotFound(nil, "COMMA-de3z9", "Errors.Instance.Domain.NotFound") + } + return nil, nil } + err = c.pushAppendAndReduce(ctx, model, instance.NewTrustedDomainRemovedEvent(ctx, InstanceAggregateFromWriteModel(&model.WriteModel), trustedDomain)) if err != nil { return nil, err diff --git a/internal/command/instance_trusted_domain_test.go b/internal/command/instance_trusted_domain_test.go index 4ba9f773e..53c7b2c95 100644 --- a/internal/command/instance_trusted_domain_test.go +++ b/internal/command/instance_trusted_domain_test.go @@ -129,8 +129,9 @@ func TestCommands_RemoveTrustedDomain(t *testing.T) { eventstore func(*testing.T) *eventstore.Eventstore } type args struct { - ctx context.Context - trustedDomain string + ctx context.Context + trustedDomain string + errorIfNotFound bool } type want struct { details *domain.ObjectDetails @@ -182,20 +183,35 @@ func TestCommands_RemoveTrustedDomain(t *testing.T) { }, }, { - name: "domain does not exists, error", + name: "domain does not exist and flag to error set should return error", fields: fields{ eventstore: expectEventstore( expectFilter(), ), }, args: args{ - ctx: authz.WithInstanceID(context.Background(), "instanceID"), - trustedDomain: "domain.com", + ctx: authz.WithInstanceID(context.Background(), "instanceID"), + trustedDomain: "domain.com", + errorIfNotFound: true, }, want: want{ err: zerrors.ThrowNotFound(nil, "COMMA-de3z9", "Errors.Instance.Domain.NotFound"), }, }, + { + name: "domain does not exist and flag to error not set should return no error", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + ), + }, + args: args{ + ctx: authz.WithInstanceID(context.Background(), "instanceID"), + trustedDomain: "domain.com", + errorIfNotFound: false, + }, + want: want{}, + }, { name: "domain remove ok", fields: fields{ @@ -228,7 +244,7 @@ func TestCommands_RemoveTrustedDomain(t *testing.T) { c := &Commands{ eventstore: tt.fields.eventstore(t), } - got, err := c.RemoveTrustedDomain(tt.args.ctx, tt.args.trustedDomain) + got, err := c.RemoveTrustedDomain(tt.args.ctx, tt.args.trustedDomain, tt.args.errorIfNotFound) assert.ErrorIs(t, err, tt.want.err) assertObjectDetails(t, tt.want.details, got) })