mirror of
https://github.com/zitadel/zitadel.git
synced 2025-12-31 15:26:51 +00:00
feat(api): move instance service to v2 (#10919)
# Which Problems Are Solved
As part of our efforts to simplify the structure and versions of our
APIs, were moving all existing v2beta endpoints to v2 and deprecate
them. They will be removed in Zitadel V5.
# How the Problems Are Solved
- This PR moves instance v2beta service and its endpoints to a
corresponding v2 version. The v2beta service and endpoints are
deprecated.
- The docs are moved to the new GA service and its endpoints. The v2beta
is not displayed anymore.
- The comments and have been improved and, where not already done, moved
from swagger annotations to proto.
- All required fields have been marked with (google.api.field_behavior)
= REQUIRED and validation rules have been added where missing
- `Domain` has been renamed to `CustomDomain` to align with naming
conventions
- `..Query` has been renamed to `..Filter` to align with other services
- The `instance_id` parameter can now passed on all endpoints and is
properly used, but requires `system` permissions. It can be omitted to
use the own instance (identified by context as any other service).
- The following endpoints are affected:
- GetInstance
- UpdateInstance
- ListCustomDomains
- AddTrustedDomain
- RemoveTrustedDomain
- ListTrustedDomains
- InstanceService has been added the InstanceInterceptor's
`explicitInstanceIdServices` to allow passing the id
- If the instance is not found by id, the error is not directly returned
to prevent enumeration.
- Permissions are checked in the API instead of the interceptor for
these calls.
- Setting the same instance name in the update no longer returns an
error, but the previous change date.
# Additional Changes
none
# Additional Context
- part of https://github.com/zitadel/zitadel/issues/10772
- requires backport to v4.x
(cherry picked from commit c2a0b9d187)
This commit is contained in:
@@ -28,6 +28,7 @@ import (
|
||||
"github.com/zitadel/zitadel/internal/telemetry/metrics"
|
||||
"github.com/zitadel/zitadel/internal/telemetry/tracing"
|
||||
"github.com/zitadel/zitadel/internal/zerrors"
|
||||
instance_pb "github.com/zitadel/zitadel/pkg/grpc/instance/v2"
|
||||
system_pb "github.com/zitadel/zitadel/pkg/grpc/system"
|
||||
)
|
||||
|
||||
@@ -192,7 +193,7 @@ func (a *API) registerConnectServer(service server.ConnectServer) {
|
||||
connect_middleware.CallDurationHandler(),
|
||||
connect_middleware.MetricsHandler(metricTypes, grpc_api.Probes...),
|
||||
connect_middleware.NoCacheInterceptor(),
|
||||
connect_middleware.InstanceInterceptor(a.queries, a.externalDomain, a.translator, system_pb.SystemService_ServiceDesc.ServiceName, healthpb.Health_ServiceDesc.ServiceName),
|
||||
connect_middleware.InstanceInterceptor(a.queries, a.externalDomain, a.translator, system_pb.SystemService_ServiceDesc.ServiceName, healthpb.Health_ServiceDesc.ServiceName, instance_pb.InstanceService_ServiceDesc.ServiceName),
|
||||
connect_middleware.AccessStorageInterceptor(a.accessInterceptor.AccessService()),
|
||||
connect_middleware.ErrorHandler(),
|
||||
connect_middleware.LimitsInterceptor(system_pb.SystemService_ServiceDesc.ServiceName),
|
||||
|
||||
246
internal/api/grpc/instance/v2/converter.go
Normal file
246
internal/api/grpc/instance/v2/converter.go
Normal file
@@ -0,0 +1,246 @@
|
||||
package instance
|
||||
|
||||
import (
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
||||
"github.com/zitadel/zitadel/cmd/build"
|
||||
"github.com/zitadel/zitadel/internal/api/grpc/filter/v2"
|
||||
"github.com/zitadel/zitadel/internal/api/grpc/object/v2"
|
||||
"github.com/zitadel/zitadel/internal/config/systemdefaults"
|
||||
"github.com/zitadel/zitadel/internal/query"
|
||||
"github.com/zitadel/zitadel/internal/zerrors"
|
||||
"github.com/zitadel/zitadel/pkg/grpc/instance/v2"
|
||||
)
|
||||
|
||||
func InstancesToPb(instances []*query.Instance) []*instance.Instance {
|
||||
list := []*instance.Instance{}
|
||||
for _, instance := range instances {
|
||||
list = append(list, ToProtoObject(instance))
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
func ToProtoObject(inst *query.Instance) *instance.Instance {
|
||||
return &instance.Instance{
|
||||
Id: inst.ID,
|
||||
Name: inst.Name,
|
||||
CustomDomains: DomainsToPb(inst.Domains),
|
||||
Version: build.Version(),
|
||||
ChangeDate: timestamppb.New(inst.ChangeDate),
|
||||
CreationDate: timestamppb.New(inst.CreationDate),
|
||||
}
|
||||
}
|
||||
|
||||
func DomainsToPb(domains []*query.InstanceDomain) []*instance.CustomDomain {
|
||||
d := []*instance.CustomDomain{}
|
||||
for _, dm := range domains {
|
||||
pbDomain := DomainToPb(dm)
|
||||
d = append(d, pbDomain)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func DomainToPb(d *query.InstanceDomain) *instance.CustomDomain {
|
||||
return &instance.CustomDomain{
|
||||
Domain: d.Domain,
|
||||
Primary: d.IsPrimary,
|
||||
Generated: d.IsGenerated,
|
||||
InstanceId: d.InstanceID,
|
||||
CreationDate: timestamppb.New(d.CreationDate),
|
||||
}
|
||||
}
|
||||
|
||||
func ListInstancesRequestToModel(req *instance.ListInstancesRequest, sysDefaults systemdefaults.SystemDefaults) (*query.InstanceSearchQueries, error) {
|
||||
offset, limit, asc, err := filter.PaginationPbToQuery(sysDefaults, req.GetPagination())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
queries, err := filtersToQueries(req.GetFilters())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &query.InstanceSearchQueries{
|
||||
SearchRequest: query.SearchRequest{
|
||||
Offset: offset,
|
||||
Limit: limit,
|
||||
Asc: asc,
|
||||
SortingColumn: fieldNameToInstanceColumn(req.GetSortingColumn()),
|
||||
},
|
||||
Queries: queries,
|
||||
}, nil
|
||||
|
||||
}
|
||||
|
||||
func fieldNameToInstanceColumn(fieldName instance.FieldName) query.Column {
|
||||
switch fieldName {
|
||||
case instance.FieldName_FIELD_NAME_ID:
|
||||
return query.InstanceColumnID
|
||||
case instance.FieldName_FIELD_NAME_NAME:
|
||||
return query.InstanceColumnName
|
||||
case instance.FieldName_FIELD_NAME_CREATION_DATE:
|
||||
return query.InstanceColumnCreationDate
|
||||
case instance.FieldName_FIELD_NAME_UNSPECIFIED:
|
||||
fallthrough
|
||||
default:
|
||||
return query.Column{}
|
||||
}
|
||||
}
|
||||
|
||||
func filtersToQueries(filters []*instance.Filter) (_ []query.SearchQuery, err error) {
|
||||
q := []query.SearchQuery{}
|
||||
for _, filter := range filters {
|
||||
model, err := instanceFilterToQuery(filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q = append(q, model)
|
||||
}
|
||||
return q, nil
|
||||
}
|
||||
|
||||
func instanceFilterToQuery(filter *instance.Filter) (query.SearchQuery, error) {
|
||||
switch q := filter.GetFilter().(type) {
|
||||
case *instance.Filter_InIdsFilter:
|
||||
return query.NewInstanceIDsListSearchQuery(q.InIdsFilter.GetIds()...)
|
||||
case *instance.Filter_CustomDomainsFilter:
|
||||
return query.NewInstanceDomainsListSearchQuery(q.CustomDomainsFilter.GetDomains()...)
|
||||
default:
|
||||
return nil, zerrors.ThrowInvalidArgument(nil, "INST-3m0se", "List.Query.Invalid")
|
||||
}
|
||||
}
|
||||
|
||||
func ListCustomDomainsRequestToModel(req *instance.ListCustomDomainsRequest, defaults systemdefaults.SystemDefaults) (*query.InstanceDomainSearchQueries, error) {
|
||||
offset, limit, asc, err := filter.PaginationPbToQuery(defaults, req.GetPagination())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
queries, err := customDomainFiltersToQueries(req.GetFilters())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &query.InstanceDomainSearchQueries{
|
||||
SearchRequest: query.SearchRequest{
|
||||
Offset: offset,
|
||||
Limit: limit,
|
||||
Asc: asc,
|
||||
SortingColumn: fieldNameToInstanceDomainColumn(req.GetSortingColumn()),
|
||||
},
|
||||
Queries: queries,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func fieldNameToInstanceDomainColumn(fieldName instance.DomainFieldName) query.Column {
|
||||
switch fieldName {
|
||||
case instance.DomainFieldName_DOMAIN_FIELD_NAME_DOMAIN:
|
||||
return query.InstanceDomainDomainCol
|
||||
case instance.DomainFieldName_DOMAIN_FIELD_NAME_GENERATED:
|
||||
return query.InstanceDomainIsGeneratedCol
|
||||
case instance.DomainFieldName_DOMAIN_FIELD_NAME_PRIMARY:
|
||||
return query.InstanceDomainIsPrimaryCol
|
||||
case instance.DomainFieldName_DOMAIN_FIELD_NAME_CREATION_DATE:
|
||||
return query.InstanceDomainCreationDateCol
|
||||
case instance.DomainFieldName_DOMAIN_FIELD_NAME_UNSPECIFIED:
|
||||
fallthrough
|
||||
default:
|
||||
return query.Column{}
|
||||
}
|
||||
}
|
||||
|
||||
func customDomainFiltersToQueries(filters []*instance.CustomDomainFilter) (_ []query.SearchQuery, err error) {
|
||||
q := make([]query.SearchQuery, len(filters))
|
||||
for i, filter := range filters {
|
||||
q[i], err = customDomainFilterToQuery(filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return q, nil
|
||||
}
|
||||
|
||||
func customDomainFilterToQuery(filter *instance.CustomDomainFilter) (query.SearchQuery, error) {
|
||||
switch q := filter.GetFilter().(type) {
|
||||
case *instance.CustomDomainFilter_DomainFilter:
|
||||
return query.NewInstanceDomainDomainSearchQuery(object.TextMethodToQuery(q.DomainFilter.GetMethod()), q.DomainFilter.GetDomain())
|
||||
case *instance.CustomDomainFilter_GeneratedFilter:
|
||||
return query.NewInstanceDomainGeneratedSearchQuery(q.GeneratedFilter)
|
||||
case *instance.CustomDomainFilter_PrimaryFilter:
|
||||
return query.NewInstanceDomainPrimarySearchQuery(q.PrimaryFilter)
|
||||
default:
|
||||
return nil, zerrors.ThrowInvalidArgument(nil, "INST-Ags42", "List.Query.Invalid")
|
||||
}
|
||||
}
|
||||
|
||||
func ListTrustedDomainsRequestToModel(req *instance.ListTrustedDomainsRequest, defaults systemdefaults.SystemDefaults) (*query.InstanceTrustedDomainSearchQueries, error) {
|
||||
offset, limit, asc, err := filter.PaginationPbToQuery(defaults, req.GetPagination())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
queries, err := trustedDomainFiltersToQueries(req.GetFilters())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &query.InstanceTrustedDomainSearchQueries{
|
||||
SearchRequest: query.SearchRequest{
|
||||
Offset: offset,
|
||||
Limit: limit,
|
||||
Asc: asc,
|
||||
SortingColumn: fieldNameToInstanceTrustedDomainColumn(req.GetSortingColumn()),
|
||||
},
|
||||
Queries: queries,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func trustedDomainFiltersToQueries(filters []*instance.TrustedDomainFilter) (_ []query.SearchQuery, err error) {
|
||||
q := make([]query.SearchQuery, len(filters))
|
||||
for i, filter := range filters {
|
||||
q[i], err = trustedDomainQueryToModel(filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return q, nil
|
||||
}
|
||||
|
||||
func trustedDomainQueryToModel(filter *instance.TrustedDomainFilter) (query.SearchQuery, error) {
|
||||
switch q := filter.GetFilter().(type) {
|
||||
case *instance.TrustedDomainFilter_DomainFilter:
|
||||
return query.NewInstanceTrustedDomainDomainSearchQuery(object.TextMethodToQuery(q.DomainFilter.GetMethod()), q.DomainFilter.GetDomain())
|
||||
default:
|
||||
return nil, zerrors.ThrowInvalidArgument(nil, "INST-Ags42", "List.Query.Invalid")
|
||||
}
|
||||
}
|
||||
|
||||
func trustedDomainsToPb(domains []*query.InstanceTrustedDomain) []*instance.TrustedDomain {
|
||||
d := make([]*instance.TrustedDomain, len(domains))
|
||||
for i, domain := range domains {
|
||||
d[i] = trustedDomainToPb(domain)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func trustedDomainToPb(d *query.InstanceTrustedDomain) *instance.TrustedDomain {
|
||||
return &instance.TrustedDomain{
|
||||
Domain: d.Domain,
|
||||
InstanceId: d.InstanceID,
|
||||
CreationDate: timestamppb.New(d.CreationDate),
|
||||
}
|
||||
}
|
||||
|
||||
func fieldNameToInstanceTrustedDomainColumn(fieldName instance.TrustedDomainFieldName) query.Column {
|
||||
switch fieldName {
|
||||
case instance.TrustedDomainFieldName_TRUSTED_DOMAIN_FIELD_NAME_DOMAIN:
|
||||
return query.InstanceTrustedDomainDomainCol
|
||||
case instance.TrustedDomainFieldName_TRUSTED_DOMAIN_FIELD_NAME_CREATION_DATE:
|
||||
return query.InstanceTrustedDomainCreationDateCol
|
||||
case instance.TrustedDomainFieldName_TRUSTED_DOMAIN_FIELD_NAME_UNSPECIFIED:
|
||||
fallthrough
|
||||
default:
|
||||
return query.Column{}
|
||||
}
|
||||
}
|
||||
391
internal/api/grpc/instance/v2/converter_test.go
Normal file
391
internal/api/grpc/instance/v2/converter_test.go
Normal file
@@ -0,0 +1,391 @@
|
||||
package instance
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
||||
"github.com/zitadel/zitadel/cmd/build"
|
||||
"github.com/zitadel/zitadel/internal/config/systemdefaults"
|
||||
"github.com/zitadel/zitadel/internal/query"
|
||||
"github.com/zitadel/zitadel/internal/zerrors"
|
||||
"github.com/zitadel/zitadel/pkg/grpc/filter/v2"
|
||||
"github.com/zitadel/zitadel/pkg/grpc/instance/v2"
|
||||
"github.com/zitadel/zitadel/pkg/grpc/object/v2"
|
||||
)
|
||||
|
||||
func Test_InstancesToPb(t *testing.T) {
|
||||
instances := []*query.Instance{
|
||||
{
|
||||
ID: "instance1",
|
||||
Name: "Instance One",
|
||||
Domains: []*query.InstanceDomain{
|
||||
{
|
||||
Domain: "example.com",
|
||||
IsPrimary: true,
|
||||
IsGenerated: false,
|
||||
Sequence: 1,
|
||||
CreationDate: time.Unix(123, 0),
|
||||
ChangeDate: time.Unix(124, 0),
|
||||
InstanceID: "instance1",
|
||||
},
|
||||
},
|
||||
Sequence: 1,
|
||||
CreationDate: time.Unix(123, 0),
|
||||
ChangeDate: time.Unix(124, 0),
|
||||
},
|
||||
}
|
||||
|
||||
want := []*instance.Instance{
|
||||
{
|
||||
Id: "instance1",
|
||||
Name: "Instance One",
|
||||
CustomDomains: []*instance.CustomDomain{
|
||||
{
|
||||
Domain: "example.com",
|
||||
Primary: true,
|
||||
Generated: false,
|
||||
InstanceId: "instance1",
|
||||
CreationDate: ×tamppb.Timestamp{Seconds: 123},
|
||||
},
|
||||
},
|
||||
Version: build.Version(),
|
||||
ChangeDate: ×tamppb.Timestamp{Seconds: 124},
|
||||
CreationDate: ×tamppb.Timestamp{Seconds: 123},
|
||||
},
|
||||
}
|
||||
|
||||
got := InstancesToPb(instances)
|
||||
assert.Equal(t, want, got)
|
||||
}
|
||||
|
||||
func Test_ListInstancesRequestToModel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
searchInstanceByID, err := query.NewInstanceIDsListSearchQuery("instance1", "instance2")
|
||||
require.Nil(t, err)
|
||||
|
||||
tt := []struct {
|
||||
testName string
|
||||
inputRequest *instance.ListInstancesRequest
|
||||
maxQueryLimit uint64
|
||||
expectedResult *query.InstanceSearchQueries
|
||||
expectedError error
|
||||
}{
|
||||
{
|
||||
testName: "when query limit exceeds max query limit should return invalid argument error",
|
||||
maxQueryLimit: 1,
|
||||
inputRequest: &instance.ListInstancesRequest{
|
||||
Pagination: &filter.PaginationRequest{Limit: 10, Offset: 0, Asc: true},
|
||||
SortingColumn: instance.FieldName_FIELD_NAME_ID,
|
||||
Filters: []*instance.Filter{{Filter: &instance.Filter_InIdsFilter{InIdsFilter: &filter.InIDsFilter{Ids: []string{"instance1", "instance2"}}}}},
|
||||
},
|
||||
expectedError: zerrors.ThrowInvalidArgumentf(errors.New("given: 10, allowed: 1"), "QUERY-4M0fs", "Errors.Query.LimitExceeded"),
|
||||
},
|
||||
{
|
||||
testName: "when valid request should return instance search query model",
|
||||
inputRequest: &instance.ListInstancesRequest{
|
||||
Pagination: &filter.PaginationRequest{Limit: 10, Offset: 0, Asc: true},
|
||||
SortingColumn: instance.FieldName_FIELD_NAME_ID,
|
||||
Filters: []*instance.Filter{{Filter: &instance.Filter_InIdsFilter{InIdsFilter: &filter.InIDsFilter{Ids: []string{"instance1", "instance2"}}}}},
|
||||
},
|
||||
expectedResult: &query.InstanceSearchQueries{
|
||||
SearchRequest: query.SearchRequest{
|
||||
Offset: 0,
|
||||
Limit: 10,
|
||||
Asc: true,
|
||||
SortingColumn: query.InstanceColumnID,
|
||||
},
|
||||
Queries: []query.SearchQuery{searchInstanceByID},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tt {
|
||||
t.Run(tc.testName, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sysDefaults := systemdefaults.SystemDefaults{MaxQueryLimit: tc.maxQueryLimit}
|
||||
|
||||
got, err := ListInstancesRequestToModel(tc.inputRequest, sysDefaults)
|
||||
assert.Equal(t, tc.expectedError, err)
|
||||
assert.Equal(t, tc.expectedResult, got)
|
||||
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_fieldNameToInstanceColumn(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
fieldName instance.FieldName
|
||||
want query.Column
|
||||
}{
|
||||
{
|
||||
name: "ID field",
|
||||
fieldName: instance.FieldName_FIELD_NAME_ID,
|
||||
want: query.InstanceColumnID,
|
||||
},
|
||||
{
|
||||
name: "Name field",
|
||||
fieldName: instance.FieldName_FIELD_NAME_NAME,
|
||||
want: query.InstanceColumnName,
|
||||
},
|
||||
{
|
||||
name: "Creation Date field",
|
||||
fieldName: instance.FieldName_FIELD_NAME_CREATION_DATE,
|
||||
want: query.InstanceColumnCreationDate,
|
||||
},
|
||||
{
|
||||
name: "Unknown field",
|
||||
fieldName: instance.FieldName(99),
|
||||
want: query.Column{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := fieldNameToInstanceColumn(tt.fieldName)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_instanceQueryToModel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
searchInstanceByID, err := query.NewInstanceIDsListSearchQuery("instance1")
|
||||
require.Nil(t, err)
|
||||
|
||||
searchInstanceByDomain, err := query.NewInstanceDomainsListSearchQuery("example.com")
|
||||
require.Nil(t, err)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
searchQuery *instance.Filter
|
||||
want query.SearchQuery
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "ID Query",
|
||||
searchQuery: &instance.Filter{
|
||||
Filter: &instance.Filter_InIdsFilter{
|
||||
InIdsFilter: &filter.InIDsFilter{
|
||||
Ids: []string{"instance1"},
|
||||
},
|
||||
},
|
||||
},
|
||||
want: searchInstanceByID,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Domain Query",
|
||||
searchQuery: &instance.Filter{
|
||||
Filter: &instance.Filter_CustomDomainsFilter{
|
||||
CustomDomainsFilter: &instance.CustomDomainsFilter{
|
||||
Domains: []string{"example.com"},
|
||||
},
|
||||
},
|
||||
},
|
||||
want: searchInstanceByDomain,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Invalid Query",
|
||||
searchQuery: &instance.Filter{
|
||||
Filter: nil,
|
||||
},
|
||||
want: nil,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := instanceFilterToQuery(tt.searchQuery)
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, tt.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_ListCustomDomainsRequestToModel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
querySearchRes, err := query.NewInstanceDomainDomainSearchQuery(query.TextEquals, "example.com")
|
||||
require.Nil(t, err)
|
||||
|
||||
queryGeneratedRes, err := query.NewInstanceDomainGeneratedSearchQuery(false)
|
||||
require.Nil(t, err)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
inputRequest *instance.ListCustomDomainsRequest
|
||||
maxQueryLimit uint64
|
||||
expectedResult *query.InstanceDomainSearchQueries
|
||||
expectedError error
|
||||
}{
|
||||
{
|
||||
name: "when query limit exceeds max query limit should return invalid argument error",
|
||||
inputRequest: &instance.ListCustomDomainsRequest{
|
||||
Pagination: &filter.PaginationRequest{Limit: 10, Offset: 0, Asc: true},
|
||||
SortingColumn: instance.DomainFieldName_DOMAIN_FIELD_NAME_DOMAIN,
|
||||
Filters: []*instance.CustomDomainFilter{
|
||||
{
|
||||
Filter: &instance.CustomDomainFilter_DomainFilter{
|
||||
DomainFilter: &instance.DomainFilter{
|
||||
Method: object.TextQueryMethod_TEXT_QUERY_METHOD_EQUALS,
|
||||
Domain: "example.com",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
maxQueryLimit: 1,
|
||||
expectedError: zerrors.ThrowInvalidArgumentf(errors.New("given: 10, allowed: 1"), "QUERY-4M0fs", "Errors.Query.LimitExceeded"),
|
||||
},
|
||||
{
|
||||
name: "when valid request should return domain search query model",
|
||||
inputRequest: &instance.ListCustomDomainsRequest{
|
||||
Pagination: &filter.PaginationRequest{Limit: 10, Offset: 0, Asc: true},
|
||||
SortingColumn: instance.DomainFieldName_DOMAIN_FIELD_NAME_PRIMARY,
|
||||
Filters: []*instance.CustomDomainFilter{
|
||||
{
|
||||
Filter: &instance.CustomDomainFilter_DomainFilter{
|
||||
DomainFilter: &instance.DomainFilter{Method: object.TextQueryMethod_TEXT_QUERY_METHOD_EQUALS, Domain: "example.com"}},
|
||||
},
|
||||
{
|
||||
Filter: &instance.CustomDomainFilter_GeneratedFilter{
|
||||
GeneratedFilter: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
maxQueryLimit: 100,
|
||||
expectedResult: &query.InstanceDomainSearchQueries{
|
||||
SearchRequest: query.SearchRequest{Offset: 0, Limit: 10, Asc: true, SortingColumn: query.InstanceDomainIsPrimaryCol},
|
||||
Queries: []query.SearchQuery{
|
||||
querySearchRes,
|
||||
queryGeneratedRes,
|
||||
},
|
||||
},
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
name: "when invalid query should return error",
|
||||
inputRequest: &instance.ListCustomDomainsRequest{
|
||||
Pagination: &filter.PaginationRequest{Limit: 10, Offset: 0, Asc: true},
|
||||
SortingColumn: instance.DomainFieldName_DOMAIN_FIELD_NAME_GENERATED,
|
||||
Filters: []*instance.CustomDomainFilter{
|
||||
{
|
||||
Filter: nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
maxQueryLimit: 100,
|
||||
expectedResult: nil,
|
||||
expectedError: zerrors.ThrowInvalidArgument(nil, "INST-Ags42", "List.Query.Invalid"),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sysDefaults := systemdefaults.SystemDefaults{MaxQueryLimit: tt.maxQueryLimit}
|
||||
|
||||
got, err := ListCustomDomainsRequestToModel(tt.inputRequest, sysDefaults)
|
||||
assert.Equal(t, tt.expectedError, err)
|
||||
assert.Equal(t, tt.expectedResult, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_ListTrustedDomainsRequestToModel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
querySearchRes, err := query.NewInstanceTrustedDomainDomainSearchQuery(query.TextEquals, "example.com")
|
||||
require.Nil(t, err)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
inputRequest *instance.ListTrustedDomainsRequest
|
||||
maxQueryLimit uint64
|
||||
expectedResult *query.InstanceTrustedDomainSearchQueries
|
||||
expectedError error
|
||||
}{
|
||||
{
|
||||
name: "when query limit exceeds max query limit should return invalid argument error",
|
||||
inputRequest: &instance.ListTrustedDomainsRequest{
|
||||
Pagination: &filter.PaginationRequest{Limit: 10, Offset: 0, Asc: true},
|
||||
SortingColumn: instance.TrustedDomainFieldName_TRUSTED_DOMAIN_FIELD_NAME_DOMAIN,
|
||||
Filters: []*instance.TrustedDomainFilter{
|
||||
{
|
||||
Filter: &instance.TrustedDomainFilter_DomainFilter{
|
||||
DomainFilter: &instance.DomainFilter{
|
||||
Method: object.TextQueryMethod_TEXT_QUERY_METHOD_EQUALS,
|
||||
Domain: "example.com",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
maxQueryLimit: 1,
|
||||
expectedError: zerrors.ThrowInvalidArgumentf(errors.New("given: 10, allowed: 1"), "QUERY-4M0fs", "Errors.Query.LimitExceeded"),
|
||||
},
|
||||
{
|
||||
name: "when valid request should return domain search query model",
|
||||
inputRequest: &instance.ListTrustedDomainsRequest{
|
||||
Pagination: &filter.PaginationRequest{Limit: 10, Offset: 0, Asc: true},
|
||||
SortingColumn: instance.TrustedDomainFieldName_TRUSTED_DOMAIN_FIELD_NAME_CREATION_DATE,
|
||||
Filters: []*instance.TrustedDomainFilter{
|
||||
{
|
||||
Filter: &instance.TrustedDomainFilter_DomainFilter{
|
||||
DomainFilter: &instance.DomainFilter{Method: object.TextQueryMethod_TEXT_QUERY_METHOD_EQUALS, Domain: "example.com"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
maxQueryLimit: 100,
|
||||
expectedResult: &query.InstanceTrustedDomainSearchQueries{
|
||||
SearchRequest: query.SearchRequest{Offset: 0, Limit: 10, Asc: true, SortingColumn: query.InstanceTrustedDomainCreationDateCol},
|
||||
Queries: []query.SearchQuery{querySearchRes},
|
||||
},
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
name: "when invalid query should return error",
|
||||
inputRequest: &instance.ListTrustedDomainsRequest{
|
||||
Pagination: &filter.PaginationRequest{Limit: 10, Offset: 0, Asc: true},
|
||||
Filters: []*instance.TrustedDomainFilter{
|
||||
{
|
||||
Filter: nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
maxQueryLimit: 100,
|
||||
expectedResult: nil,
|
||||
expectedError: zerrors.ThrowInvalidArgument(nil, "INST-Ags42", "List.Query.Invalid"),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sysDefaults := systemdefaults.SystemDefaults{MaxQueryLimit: tt.maxQueryLimit}
|
||||
|
||||
got, err := ListTrustedDomainsRequestToModel(tt.inputRequest, sysDefaults)
|
||||
assert.Equal(t, tt.expectedError, err)
|
||||
assert.Equal(t, tt.expectedResult, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
62
internal/api/grpc/instance/v2/domain.go
Normal file
62
internal/api/grpc/instance/v2/domain.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package instance
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"connectrpc.com/connect"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/domain"
|
||||
"github.com/zitadel/zitadel/pkg/grpc/instance/v2"
|
||||
)
|
||||
|
||||
func (s *Server) AddCustomDomain(ctx context.Context, req *connect.Request[instance.AddCustomDomainRequest]) (*connect.Response[instance.AddCustomDomainResponse], error) {
|
||||
// Adding a custom domain is currently only allowed with system permissions,
|
||||
// so we directly check for them in the auth interceptor and do not check here again.
|
||||
details, err := s.command.AddInstanceDomain(ctx, req.Msg.GetCustomDomain())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return connect.NewResponse(&instance.AddCustomDomainResponse{
|
||||
CreationDate: timestamppb.New(details.EventDate),
|
||||
}), nil
|
||||
}
|
||||
|
||||
func (s *Server) RemoveCustomDomain(ctx context.Context, req *connect.Request[instance.RemoveCustomDomainRequest]) (*connect.Response[instance.RemoveCustomDomainResponse], error) {
|
||||
// Removing a custom domain is currently only allowed with system permissions,
|
||||
// so we directly check for them in the auth interceptor and do not check here again.
|
||||
details, err := s.command.RemoveInstanceDomain(ctx, req.Msg.GetCustomDomain())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return connect.NewResponse(&instance.RemoveCustomDomainResponse{
|
||||
DeletionDate: timestamppb.New(details.EventDate),
|
||||
}), nil
|
||||
}
|
||||
|
||||
func (s *Server) AddTrustedDomain(ctx context.Context, req *connect.Request[instance.AddTrustedDomainRequest]) (*connect.Response[instance.AddTrustedDomainResponse], error) {
|
||||
if err := s.checkPermission(ctx, domain.PermissionSystemInstanceWrite, domain.PermissionInstanceWrite); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
details, err := s.command.AddTrustedDomain(ctx, req.Msg.GetTrustedDomain())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return connect.NewResponse(&instance.AddTrustedDomainResponse{
|
||||
CreationDate: timestamppb.New(details.EventDate),
|
||||
}), nil
|
||||
}
|
||||
|
||||
func (s *Server) RemoveTrustedDomain(ctx context.Context, req *connect.Request[instance.RemoveTrustedDomainRequest]) (*connect.Response[instance.RemoveTrustedDomainResponse], error) {
|
||||
if err := s.checkPermission(ctx, domain.PermissionSystemInstanceWrite, domain.PermissionInstanceWrite); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
details, err := s.command.RemoveTrustedDomain(ctx, req.Msg.GetTrustedDomain())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return connect.NewResponse(&instance.RemoveTrustedDomainResponse{
|
||||
DeletionDate: timestamppb.New(details.EventDate),
|
||||
}), nil
|
||||
}
|
||||
39
internal/api/grpc/instance/v2/instance.go
Normal file
39
internal/api/grpc/instance/v2/instance.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package instance
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"connectrpc.com/connect"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/domain"
|
||||
"github.com/zitadel/zitadel/pkg/grpc/instance/v2"
|
||||
)
|
||||
|
||||
func (s *Server) DeleteInstance(ctx context.Context, request *connect.Request[instance.DeleteInstanceRequest]) (*connect.Response[instance.DeleteInstanceResponse], error) {
|
||||
// Deleting an instance is currently only allowed with system permissions,
|
||||
// so we directly check for them in the auth interceptor and do not check here again.
|
||||
obj, err := s.command.RemoveInstance(ctx, request.Msg.GetInstanceId())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return connect.NewResponse(&instance.DeleteInstanceResponse{
|
||||
DeletionDate: timestamppb.New(obj.EventDate),
|
||||
}), nil
|
||||
|
||||
}
|
||||
|
||||
func (s *Server) UpdateInstance(ctx context.Context, request *connect.Request[instance.UpdateInstanceRequest]) (*connect.Response[instance.UpdateInstanceResponse], error) {
|
||||
if err := s.checkPermission(ctx, domain.PermissionSystemInstanceWrite, domain.PermissionInstanceWrite); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
obj, err := s.command.UpdateInstance(ctx, request.Msg.GetInstanceName())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return connect.NewResponse(&instance.UpdateInstanceResponse{
|
||||
ChangeDate: timestamppb.New(obj.EventDate),
|
||||
}), nil
|
||||
}
|
||||
349
internal/api/grpc/instance/v2/integration_test/domain_test.go
Normal file
349
internal/api/grpc/instance/v2/integration_test/domain_test.go
Normal file
@@ -0,0 +1,349 @@
|
||||
//go:build integration
|
||||
|
||||
package instance_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/integration"
|
||||
"github.com/zitadel/zitadel/pkg/grpc/instance/v2"
|
||||
)
|
||||
|
||||
func TestAddCustomDomain(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Given
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
ctxWithSysAuthZ := integration.WithSystemAuthorization(ctx)
|
||||
|
||||
inst := integration.NewInstance(ctxWithSysAuthZ)
|
||||
iamOwnerCtx := inst.WithAuthorizationToken(context.Background(), integration.UserTypeIAMOwner)
|
||||
|
||||
t.Cleanup(func() {
|
||||
inst.Client.InstanceV2.DeleteInstance(ctxWithSysAuthZ, &instance.DeleteInstanceRequest{InstanceId: inst.ID()})
|
||||
})
|
||||
|
||||
tt := []struct {
|
||||
testName string
|
||||
inputContext context.Context
|
||||
inputRequest *instance.AddCustomDomainRequest
|
||||
expectedErrorMsg string
|
||||
expectedErrorCode codes.Code
|
||||
}{
|
||||
{
|
||||
testName: "when invalid context should return unauthN error",
|
||||
inputRequest: &instance.AddCustomDomainRequest{
|
||||
InstanceId: inst.ID(),
|
||||
CustomDomain: integration.DomainName(),
|
||||
},
|
||||
inputContext: context.Background(),
|
||||
expectedErrorCode: codes.Unauthenticated,
|
||||
expectedErrorMsg: "auth header missing",
|
||||
},
|
||||
{
|
||||
testName: "when unauthZ context should return unauthZ error",
|
||||
inputRequest: &instance.AddCustomDomainRequest{
|
||||
InstanceId: inst.ID(),
|
||||
CustomDomain: integration.DomainName(),
|
||||
},
|
||||
inputContext: iamOwnerCtx,
|
||||
expectedErrorCode: codes.PermissionDenied,
|
||||
expectedErrorMsg: "No matching permissions found (AUTH-5mWD2)",
|
||||
},
|
||||
{
|
||||
testName: "when invalid domain should return invalid argument error",
|
||||
inputRequest: &instance.AddCustomDomainRequest{
|
||||
InstanceId: inst.ID(),
|
||||
CustomDomain: " ",
|
||||
},
|
||||
inputContext: ctxWithSysAuthZ,
|
||||
expectedErrorCode: codes.InvalidArgument,
|
||||
expectedErrorMsg: "Errors.Invalid.Argument (INST-28nlD)",
|
||||
},
|
||||
{
|
||||
testName: "when valid request should return successful response",
|
||||
inputRequest: &instance.AddCustomDomainRequest{
|
||||
InstanceId: inst.ID(),
|
||||
CustomDomain: " " + integration.DomainName(),
|
||||
},
|
||||
inputContext: ctxWithSysAuthZ,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tt {
|
||||
t.Run(tc.testName, func(t *testing.T) {
|
||||
t.Cleanup(func() {
|
||||
if tc.expectedErrorMsg == "" {
|
||||
inst.Client.InstanceV2.RemoveCustomDomain(ctxWithSysAuthZ, &instance.RemoveCustomDomainRequest{CustomDomain: strings.TrimSpace(tc.inputRequest.CustomDomain)})
|
||||
}
|
||||
})
|
||||
|
||||
// Test
|
||||
res, err := inst.Client.InstanceV2.AddCustomDomain(tc.inputContext, tc.inputRequest)
|
||||
|
||||
// Verify
|
||||
assert.Equal(t, tc.expectedErrorCode, status.Code(err))
|
||||
assert.Equal(t, tc.expectedErrorMsg, status.Convert(err).Message())
|
||||
|
||||
if tc.expectedErrorMsg == "" {
|
||||
assert.NotNil(t, res)
|
||||
assert.NotEmpty(t, res.GetCreationDate())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveCustomDomain(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Given
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
ctxWithSysAuthZ := integration.WithSystemAuthorization(ctx)
|
||||
inst := integration.NewInstance(ctxWithSysAuthZ)
|
||||
iamOwnerCtx := inst.WithAuthorizationToken(context.Background(), integration.UserTypeIAMOwner)
|
||||
|
||||
customDomain := integration.DomainName()
|
||||
|
||||
_, err := inst.Client.InstanceV2.AddCustomDomain(ctxWithSysAuthZ, &instance.AddCustomDomainRequest{InstanceId: inst.ID(), CustomDomain: customDomain})
|
||||
require.Nil(t, err)
|
||||
|
||||
t.Cleanup(func() {
|
||||
inst.Client.InstanceV2.RemoveCustomDomain(ctxWithSysAuthZ, &instance.RemoveCustomDomainRequest{InstanceId: inst.ID(), CustomDomain: customDomain})
|
||||
inst.Client.InstanceV2.DeleteInstance(ctxWithSysAuthZ, &instance.DeleteInstanceRequest{InstanceId: inst.ID()})
|
||||
})
|
||||
|
||||
tt := []struct {
|
||||
testName string
|
||||
inputContext context.Context
|
||||
inputRequest *instance.RemoveCustomDomainRequest
|
||||
expectedErrorMsg string
|
||||
expectedErrorCode codes.Code
|
||||
}{
|
||||
{
|
||||
testName: "when invalid context should return unauthN error",
|
||||
inputRequest: &instance.RemoveCustomDomainRequest{
|
||||
InstanceId: inst.ID(),
|
||||
CustomDomain: "custom1",
|
||||
},
|
||||
inputContext: context.Background(),
|
||||
expectedErrorCode: codes.Unauthenticated,
|
||||
expectedErrorMsg: "auth header missing",
|
||||
},
|
||||
{
|
||||
testName: "when unauthZ context should return unauthZ error",
|
||||
inputRequest: &instance.RemoveCustomDomainRequest{
|
||||
InstanceId: inst.ID(),
|
||||
CustomDomain: "custom1",
|
||||
},
|
||||
inputContext: iamOwnerCtx,
|
||||
expectedErrorCode: codes.PermissionDenied,
|
||||
expectedErrorMsg: "No matching permissions found (AUTH-5mWD2)",
|
||||
},
|
||||
{
|
||||
testName: "when invalid domain should return invalid argument error",
|
||||
inputRequest: &instance.RemoveCustomDomainRequest{
|
||||
InstanceId: inst.ID(),
|
||||
CustomDomain: " ",
|
||||
},
|
||||
inputContext: ctxWithSysAuthZ,
|
||||
expectedErrorCode: codes.InvalidArgument,
|
||||
expectedErrorMsg: "Errors.Invalid.Argument (INST-39nls)",
|
||||
},
|
||||
{
|
||||
testName: "when valid request should return successful response",
|
||||
inputRequest: &instance.RemoveCustomDomainRequest{
|
||||
InstanceId: inst.ID(),
|
||||
CustomDomain: " " + customDomain,
|
||||
},
|
||||
inputContext: ctxWithSysAuthZ,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tt {
|
||||
t.Run(tc.testName, func(t *testing.T) {
|
||||
// Test
|
||||
res, err := inst.Client.InstanceV2.RemoveCustomDomain(tc.inputContext, tc.inputRequest)
|
||||
|
||||
// Verify
|
||||
assert.Equal(t, tc.expectedErrorCode, status.Code(err))
|
||||
assert.Equal(t, tc.expectedErrorMsg, status.Convert(err).Message())
|
||||
|
||||
if tc.expectedErrorMsg == "" {
|
||||
assert.NotNil(t, res)
|
||||
assert.NotEmpty(t, res.GetDeletionDate())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddTrustedDomain(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Given
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
ctxWithSysAuthZ := integration.WithSystemAuthorization(ctx)
|
||||
inst := integration.NewInstance(ctxWithSysAuthZ)
|
||||
orgOwnerCtx := inst.WithAuthorizationToken(context.Background(), integration.UserTypeOrgOwner)
|
||||
|
||||
t.Cleanup(func() {
|
||||
inst.Client.InstanceV2.DeleteInstance(ctxWithSysAuthZ, &instance.DeleteInstanceRequest{InstanceId: inst.ID()})
|
||||
})
|
||||
|
||||
tt := []struct {
|
||||
testName string
|
||||
inputContext context.Context
|
||||
inputRequest *instance.AddTrustedDomainRequest
|
||||
expectedErrorMsg string
|
||||
expectedErrorCode codes.Code
|
||||
}{
|
||||
{
|
||||
testName: "when invalid context should return unauthN error",
|
||||
inputRequest: &instance.AddTrustedDomainRequest{
|
||||
InstanceId: inst.ID(),
|
||||
TrustedDomain: "trusted1",
|
||||
},
|
||||
inputContext: context.Background(),
|
||||
expectedErrorCode: codes.Unauthenticated,
|
||||
expectedErrorMsg: "auth header missing",
|
||||
},
|
||||
{
|
||||
testName: "when unauthZ context should return unauthZ error",
|
||||
inputRequest: &instance.AddTrustedDomainRequest{
|
||||
InstanceId: inst.ID(),
|
||||
TrustedDomain: "trusted1",
|
||||
},
|
||||
inputContext: orgOwnerCtx,
|
||||
expectedErrorCode: codes.NotFound,
|
||||
expectedErrorMsg: "membership not found (AUTHZ-cdgFk)",
|
||||
},
|
||||
{
|
||||
testName: "when invalid domain should return invalid argument error",
|
||||
inputRequest: &instance.AddTrustedDomainRequest{
|
||||
InstanceId: inst.ID(),
|
||||
TrustedDomain: " ",
|
||||
},
|
||||
inputContext: ctxWithSysAuthZ,
|
||||
expectedErrorCode: codes.InvalidArgument,
|
||||
expectedErrorMsg: "Errors.Invalid.Argument (COMMA-Stk21)",
|
||||
},
|
||||
{
|
||||
testName: "when valid request should return successful response",
|
||||
inputRequest: &instance.AddTrustedDomainRequest{
|
||||
InstanceId: inst.ID(),
|
||||
TrustedDomain: " " + integration.DomainName(),
|
||||
},
|
||||
inputContext: ctxWithSysAuthZ,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tt {
|
||||
t.Run(tc.testName, func(t *testing.T) {
|
||||
t.Cleanup(func() {
|
||||
if tc.expectedErrorMsg == "" {
|
||||
inst.Client.InstanceV2.RemoveTrustedDomain(ctxWithSysAuthZ, &instance.RemoveTrustedDomainRequest{TrustedDomain: strings.TrimSpace(tc.inputRequest.TrustedDomain)})
|
||||
}
|
||||
})
|
||||
|
||||
// Test
|
||||
res, err := inst.Client.InstanceV2.AddTrustedDomain(tc.inputContext, tc.inputRequest)
|
||||
|
||||
// Verify
|
||||
assert.Equal(t, tc.expectedErrorCode, status.Code(err))
|
||||
assert.Equal(t, tc.expectedErrorMsg, status.Convert(err).Message())
|
||||
|
||||
if tc.expectedErrorMsg == "" {
|
||||
assert.NotNil(t, res)
|
||||
assert.NotEmpty(t, res.GetCreationDate())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveTrustedDomain(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Given
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
ctxWithSysAuthZ := integration.WithSystemAuthorization(ctx)
|
||||
inst := integration.NewInstance(ctxWithSysAuthZ)
|
||||
orgOwnerCtx := inst.WithAuthorizationToken(context.Background(), integration.UserTypeOrgOwner)
|
||||
|
||||
trustedDomain := integration.DomainName()
|
||||
|
||||
_, err := inst.Client.InstanceV2.AddTrustedDomain(ctxWithSysAuthZ, &instance.AddTrustedDomainRequest{InstanceId: inst.ID(), TrustedDomain: trustedDomain})
|
||||
require.Nil(t, err)
|
||||
|
||||
t.Cleanup(func() {
|
||||
inst.Client.InstanceV2.RemoveTrustedDomain(ctxWithSysAuthZ, &instance.RemoveTrustedDomainRequest{InstanceId: inst.ID(), TrustedDomain: trustedDomain})
|
||||
inst.Client.InstanceV2.DeleteInstance(ctxWithSysAuthZ, &instance.DeleteInstanceRequest{InstanceId: inst.ID()})
|
||||
})
|
||||
|
||||
tt := []struct {
|
||||
testName string
|
||||
inputContext context.Context
|
||||
inputRequest *instance.RemoveTrustedDomainRequest
|
||||
expectedErrorMsg string
|
||||
expectedErrorCode codes.Code
|
||||
}{
|
||||
{
|
||||
testName: "when invalid context should return unauthN error",
|
||||
inputRequest: &instance.RemoveTrustedDomainRequest{
|
||||
InstanceId: inst.ID(),
|
||||
TrustedDomain: "trusted1",
|
||||
},
|
||||
inputContext: context.Background(),
|
||||
expectedErrorCode: codes.Unauthenticated,
|
||||
expectedErrorMsg: "auth header missing",
|
||||
},
|
||||
{
|
||||
testName: "when unauthZ context should return unauthZ error",
|
||||
inputRequest: &instance.RemoveTrustedDomainRequest{
|
||||
InstanceId: inst.ID(),
|
||||
TrustedDomain: "trusted1",
|
||||
},
|
||||
inputContext: orgOwnerCtx,
|
||||
expectedErrorCode: codes.NotFound,
|
||||
expectedErrorMsg: "membership not found (AUTHZ-cdgFk)",
|
||||
},
|
||||
{
|
||||
testName: "when valid request should return successful response",
|
||||
inputRequest: &instance.RemoveTrustedDomainRequest{
|
||||
InstanceId: inst.ID(),
|
||||
TrustedDomain: " " + trustedDomain,
|
||||
},
|
||||
inputContext: ctxWithSysAuthZ,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tt {
|
||||
t.Run(tc.testName, func(t *testing.T) {
|
||||
// Test
|
||||
res, err := inst.Client.InstanceV2.RemoveTrustedDomain(tc.inputContext, tc.inputRequest)
|
||||
|
||||
// Verify
|
||||
assert.Equal(t, tc.expectedErrorCode, status.Code(err))
|
||||
assert.Equal(t, tc.expectedErrorMsg, status.Convert(err).Message())
|
||||
|
||||
if tc.expectedErrorMsg == "" {
|
||||
require.NotNil(t, res)
|
||||
require.NotEmpty(t, res.GetDeletionDate())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
252
internal/api/grpc/instance/v2/integration_test/instance_test.go
Normal file
252
internal/api/grpc/instance/v2/integration_test/instance_test.go
Normal file
@@ -0,0 +1,252 @@
|
||||
//go:build integration
|
||||
|
||||
package instance_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/integration"
|
||||
"github.com/zitadel/zitadel/pkg/grpc/instance/v2"
|
||||
)
|
||||
|
||||
func TestDeleteInstance(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Given
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
ctxWithSysAuthZ := integration.WithSystemAuthorization(ctx)
|
||||
|
||||
inst := integration.NewInstance(ctxWithSysAuthZ)
|
||||
instanceOwnerCtx := inst.WithAuthorizationToken(context.Background(), integration.UserTypeIAMOwner)
|
||||
inst2 := integration.NewInstance(ctxWithSysAuthZ)
|
||||
|
||||
t.Cleanup(func() {
|
||||
inst.Client.InstanceV2.DeleteInstance(ctxWithSysAuthZ, &instance.DeleteInstanceRequest{InstanceId: inst.ID()})
|
||||
})
|
||||
|
||||
tt := []struct {
|
||||
testName string
|
||||
inputRequest *instance.DeleteInstanceRequest
|
||||
inputContext context.Context
|
||||
expectedErrorMsg string
|
||||
expectedErrorCode codes.Code
|
||||
expectedInstanceID string
|
||||
}{
|
||||
{
|
||||
testName: "when invalid context should return unauthN error",
|
||||
inputRequest: &instance.DeleteInstanceRequest{
|
||||
InstanceId: inst.ID(),
|
||||
},
|
||||
inputContext: context.Background(),
|
||||
expectedErrorCode: codes.Unauthenticated,
|
||||
expectedErrorMsg: "auth header missing",
|
||||
},
|
||||
{
|
||||
testName: "when invalid context and invalid id should return unauthN error",
|
||||
inputRequest: &instance.DeleteInstanceRequest{
|
||||
InstanceId: inst.ID() + "invalid",
|
||||
},
|
||||
inputContext: context.Background(),
|
||||
expectedErrorCode: codes.Unauthenticated,
|
||||
expectedErrorMsg: "auth header missing",
|
||||
},
|
||||
{
|
||||
testName: "when instance token for invalid instance should return unauthN error",
|
||||
inputRequest: &instance.DeleteInstanceRequest{
|
||||
InstanceId: inst.ID() + "invalid",
|
||||
},
|
||||
inputContext: instanceOwnerCtx,
|
||||
expectedErrorCode: codes.Unauthenticated,
|
||||
expectedErrorMsg: "Errors.Token.Invalid (AUTH-7fs1e)",
|
||||
},
|
||||
{
|
||||
testName: "when instance token for other instance should return unauthN error",
|
||||
inputRequest: &instance.DeleteInstanceRequest{
|
||||
InstanceId: inst2.ID(),
|
||||
},
|
||||
inputContext: instanceOwnerCtx,
|
||||
expectedErrorCode: codes.Unauthenticated,
|
||||
expectedErrorMsg: "Errors.Token.Invalid (AUTH-7fs1e)",
|
||||
},
|
||||
{
|
||||
testName: "when instance token for own instance should return unauthZ error",
|
||||
inputRequest: &instance.DeleteInstanceRequest{
|
||||
InstanceId: inst.ID(),
|
||||
},
|
||||
inputContext: instanceOwnerCtx,
|
||||
expectedErrorCode: codes.PermissionDenied,
|
||||
expectedErrorMsg: "No matching permissions found (AUTH-5mWD2)",
|
||||
},
|
||||
{
|
||||
testName: "when invalid id should return not found error",
|
||||
inputRequest: &instance.DeleteInstanceRequest{
|
||||
InstanceId: inst.ID() + "invalid",
|
||||
},
|
||||
inputContext: ctxWithSysAuthZ,
|
||||
expectedErrorCode: codes.NotFound,
|
||||
expectedErrorMsg: "Errors.Instance.NotFound (COMMA-AE3GS)",
|
||||
},
|
||||
{
|
||||
testName: "when delete succeeds should return deletion date",
|
||||
inputRequest: &instance.DeleteInstanceRequest{
|
||||
InstanceId: inst.ID(),
|
||||
},
|
||||
inputContext: ctxWithSysAuthZ,
|
||||
expectedInstanceID: inst.ID(),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tt {
|
||||
t.Run(tc.testName, func(t *testing.T) {
|
||||
// Test
|
||||
res, err := inst.Client.InstanceV2.DeleteInstance(tc.inputContext, tc.inputRequest)
|
||||
|
||||
// Verify
|
||||
assert.Equal(t, tc.expectedErrorCode, status.Code(err))
|
||||
assert.Equal(t, tc.expectedErrorMsg, status.Convert(err).Message())
|
||||
if tc.expectedErrorMsg == "" {
|
||||
require.NotNil(t, res)
|
||||
require.NotEmpty(t, res.GetDeletionDate())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateInstance(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Given
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
ctxWithSysAuthZ := integration.WithSystemAuthorization(ctx)
|
||||
|
||||
inst := integration.NewInstance(ctxWithSysAuthZ)
|
||||
instanceOwnerCtx := inst.WithAuthorizationToken(context.Background(), integration.UserTypeIAMOwner)
|
||||
orgOwnerCtx := inst.WithAuthorizationToken(context.Background(), integration.UserTypeOrgOwner)
|
||||
inst2 := integration.NewInstance(ctxWithSysAuthZ)
|
||||
|
||||
t.Cleanup(func() {
|
||||
inst.Client.InstanceV2.DeleteInstance(ctxWithSysAuthZ, &instance.DeleteInstanceRequest{InstanceId: inst.ID()})
|
||||
})
|
||||
|
||||
tt := []struct {
|
||||
testName string
|
||||
inputRequest *instance.UpdateInstanceRequest
|
||||
inputContext context.Context
|
||||
expectedErrorMsg string
|
||||
expectedErrorCode codes.Code
|
||||
expectedNewName string
|
||||
}{
|
||||
{
|
||||
testName: "when invalid context should return unauthN error",
|
||||
inputRequest: &instance.UpdateInstanceRequest{
|
||||
InstanceId: inst.ID(),
|
||||
InstanceName: " ",
|
||||
},
|
||||
inputContext: context.Background(),
|
||||
expectedErrorCode: codes.Unauthenticated,
|
||||
expectedErrorMsg: "auth header missing",
|
||||
},
|
||||
{
|
||||
testName: "when invalid context and invalid id should return unauthN error",
|
||||
inputRequest: &instance.UpdateInstanceRequest{
|
||||
InstanceId: inst.ID() + "invalid",
|
||||
InstanceName: " ",
|
||||
},
|
||||
inputContext: context.Background(),
|
||||
expectedErrorCode: codes.Unauthenticated,
|
||||
expectedErrorMsg: "auth header missing",
|
||||
},
|
||||
{
|
||||
testName: "when instance token for invalid instance should return unauthN error",
|
||||
inputRequest: &instance.UpdateInstanceRequest{
|
||||
InstanceId: inst.ID() + "invalid",
|
||||
InstanceName: " ",
|
||||
},
|
||||
inputContext: instanceOwnerCtx,
|
||||
expectedErrorCode: codes.Unauthenticated,
|
||||
expectedErrorMsg: "Errors.Token.Invalid (AUTH-7fs1e)",
|
||||
},
|
||||
{
|
||||
testName: "when instance token for other instance should return unauthN error",
|
||||
inputRequest: &instance.UpdateInstanceRequest{
|
||||
InstanceId: inst2.ID(),
|
||||
InstanceName: " ",
|
||||
},
|
||||
inputContext: instanceOwnerCtx,
|
||||
expectedErrorCode: codes.Unauthenticated,
|
||||
expectedErrorMsg: "Errors.Token.Invalid (AUTH-7fs1e)",
|
||||
},
|
||||
{
|
||||
testName: "when unauthZ context should return unauthZ error",
|
||||
inputRequest: &instance.UpdateInstanceRequest{
|
||||
InstanceId: inst.ID(),
|
||||
InstanceName: " ",
|
||||
},
|
||||
inputContext: orgOwnerCtx,
|
||||
expectedErrorCode: codes.NotFound,
|
||||
expectedErrorMsg: "membership not found (AUTHZ-cdgFk)",
|
||||
},
|
||||
{
|
||||
testName: "when invalid id should return not found error",
|
||||
inputRequest: &instance.UpdateInstanceRequest{
|
||||
InstanceId: inst.ID() + "invalid",
|
||||
InstanceName: "an-updated-name",
|
||||
},
|
||||
inputContext: ctxWithSysAuthZ,
|
||||
expectedErrorCode: codes.NotFound,
|
||||
expectedErrorMsg: "Errors.Instance.NotFound (INST-nuso2m)",
|
||||
},
|
||||
{
|
||||
testName: "when update succeeds (instance owner) should change instance name",
|
||||
inputRequest: &instance.UpdateInstanceRequest{
|
||||
InstanceId: inst.ID(),
|
||||
InstanceName: "an-updated-name",
|
||||
},
|
||||
inputContext: instanceOwnerCtx,
|
||||
expectedNewName: "an-updated-name",
|
||||
},
|
||||
{
|
||||
testName: "when update succeeds should change instance name",
|
||||
inputRequest: &instance.UpdateInstanceRequest{
|
||||
InstanceId: inst2.ID(),
|
||||
InstanceName: "an-updated-name2",
|
||||
},
|
||||
inputContext: ctxWithSysAuthZ,
|
||||
expectedNewName: "an-updated-name2",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tt {
|
||||
t.Run(tc.testName, func(t *testing.T) {
|
||||
// Test
|
||||
res, err := inst.Client.InstanceV2.UpdateInstance(tc.inputContext, tc.inputRequest)
|
||||
|
||||
// Verify
|
||||
assert.Equal(t, tc.expectedErrorCode, status.Code(err))
|
||||
assert.Equal(t, tc.expectedErrorMsg, status.Convert(err).Message())
|
||||
if tc.expectedErrorMsg == "" {
|
||||
|
||||
require.NotNil(t, res)
|
||||
assert.NotEmpty(t, res.GetChangeDate())
|
||||
|
||||
retryDuration, tick := integration.WaitForAndTickWithMaxDuration(tc.inputContext, time.Minute)
|
||||
require.EventuallyWithT(t, func(tt *assert.CollectT) {
|
||||
retrievedInstance, err := inst.Client.InstanceV2.GetInstance(tc.inputContext, &instance.GetInstanceRequest{InstanceId: tc.inputRequest.GetInstanceId()})
|
||||
require.Nil(tt, err)
|
||||
assert.Equal(tt, tc.expectedNewName, retrievedInstance.GetInstance().GetName())
|
||||
}, retryDuration, tick, "timeout waiting for expected execution result")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
427
internal/api/grpc/instance/v2/integration_test/query_test.go
Normal file
427
internal/api/grpc/instance/v2/integration_test/query_test.go
Normal file
@@ -0,0 +1,427 @@
|
||||
//go:build integration
|
||||
|
||||
package instance_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/integration"
|
||||
"github.com/zitadel/zitadel/pkg/grpc/filter/v2"
|
||||
"github.com/zitadel/zitadel/pkg/grpc/instance/v2"
|
||||
"github.com/zitadel/zitadel/pkg/grpc/object/v2"
|
||||
)
|
||||
|
||||
func TestGetInstance(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Given
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
ctxWithSysAuthZ := integration.WithSystemAuthorization(ctx)
|
||||
inst := integration.NewInstance(ctxWithSysAuthZ)
|
||||
instanceOwnerCtx := inst.WithAuthorizationToken(context.Background(), integration.UserTypeIAMOwner)
|
||||
organizationOwnerCtx := inst.WithAuthorizationToken(context.Background(), integration.UserTypeOrgOwner)
|
||||
|
||||
t.Cleanup(func() {
|
||||
inst.Client.InstanceV2.DeleteInstance(ctxWithSysAuthZ, &instance.DeleteInstanceRequest{InstanceId: inst.ID()})
|
||||
})
|
||||
|
||||
tt := []struct {
|
||||
testName string
|
||||
inputContext context.Context
|
||||
inputInstanceID string
|
||||
expectedInstanceID string
|
||||
expectedErrorMsg string
|
||||
expectedErrorCode codes.Code
|
||||
}{
|
||||
{
|
||||
testName: "when unauthN context should return unauthN error",
|
||||
inputContext: context.Background(),
|
||||
inputInstanceID: inst.ID(),
|
||||
expectedErrorCode: codes.Unauthenticated,
|
||||
expectedErrorMsg: "auth header missing",
|
||||
},
|
||||
{
|
||||
testName: "when unauthZ context should return unauthZ error",
|
||||
inputContext: organizationOwnerCtx,
|
||||
inputInstanceID: inst.ID(),
|
||||
expectedErrorCode: codes.NotFound,
|
||||
expectedErrorMsg: "membership not found (AUTHZ-cdgFk)",
|
||||
},
|
||||
{
|
||||
testName: "when request succeeds should return matching instance (systemUser)",
|
||||
inputContext: ctxWithSysAuthZ,
|
||||
inputInstanceID: inst.ID(),
|
||||
expectedInstanceID: inst.ID(),
|
||||
},
|
||||
{
|
||||
testName: "when request succeeds should return matching instance (own context)",
|
||||
inputContext: instanceOwnerCtx,
|
||||
expectedInstanceID: inst.ID(),
|
||||
},
|
||||
{
|
||||
testName: "when instance not found should return not found error",
|
||||
inputContext: ctxWithSysAuthZ,
|
||||
inputInstanceID: "invalid",
|
||||
expectedErrorCode: codes.NotFound,
|
||||
expectedErrorMsg: "Errors.IAM.NotFound (QUERY-n0wng)",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tt {
|
||||
t.Run(tc.testName, func(t *testing.T) {
|
||||
// Test
|
||||
res, err := inst.Client.InstanceV2.GetInstance(tc.inputContext, &instance.GetInstanceRequest{InstanceId: tc.inputInstanceID})
|
||||
|
||||
// Verify
|
||||
assert.Equal(t, tc.expectedErrorCode, status.Code(err))
|
||||
assert.Equal(t, tc.expectedErrorMsg, status.Convert(err).Message())
|
||||
|
||||
if tc.expectedErrorMsg == "" {
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.expectedInstanceID, res.GetInstance().GetId())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestListInstances(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Given
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
ctxWithSysAuthZ := integration.WithSystemAuthorization(ctx)
|
||||
|
||||
instances := make([]*integration.Instance, 2)
|
||||
inst := integration.NewInstance(ctxWithSysAuthZ)
|
||||
inst2 := integration.NewInstance(ctxWithSysAuthZ)
|
||||
instances[0], instances[1] = inst, inst2
|
||||
|
||||
t.Cleanup(func() {
|
||||
inst.Client.InstanceV2.DeleteInstance(ctxWithSysAuthZ, &instance.DeleteInstanceRequest{InstanceId: inst.ID()})
|
||||
inst.Client.InstanceV2.DeleteInstance(ctxWithSysAuthZ, &instance.DeleteInstanceRequest{InstanceId: inst2.ID()})
|
||||
})
|
||||
|
||||
// Sort in descending order
|
||||
slices.SortFunc(instances, func(i1, i2 *integration.Instance) int {
|
||||
res := i1.Instance.Details.CreationDate.AsTime().Compare(i2.Instance.Details.CreationDate.AsTime())
|
||||
if res == 0 {
|
||||
return res
|
||||
}
|
||||
return -res
|
||||
})
|
||||
|
||||
orgOwnerCtx := inst.WithAuthorizationToken(context.Background(), integration.UserTypeOrgOwner)
|
||||
|
||||
tt := []struct {
|
||||
testName string
|
||||
inputRequest *instance.ListInstancesRequest
|
||||
inputContext context.Context
|
||||
expectedErrorMsg string
|
||||
expectedErrorCode codes.Code
|
||||
expectedInstances []string
|
||||
}{
|
||||
{
|
||||
testName: "when invalid context should return unauthN error",
|
||||
inputRequest: &instance.ListInstancesRequest{
|
||||
Pagination: &filter.PaginationRequest{Offset: 0, Limit: 10},
|
||||
},
|
||||
inputContext: context.Background(),
|
||||
expectedErrorCode: codes.Unauthenticated,
|
||||
expectedErrorMsg: "auth header missing",
|
||||
},
|
||||
{
|
||||
testName: "when unauthZ context should return unauthZ error",
|
||||
inputRequest: &instance.ListInstancesRequest{
|
||||
Pagination: &filter.PaginationRequest{Offset: 0, Limit: 10},
|
||||
},
|
||||
inputContext: orgOwnerCtx,
|
||||
expectedErrorCode: codes.Unauthenticated,
|
||||
expectedErrorMsg: "Errors.Token.Invalid (AUTH-7fs1e)",
|
||||
},
|
||||
{
|
||||
testName: "when valid request with filter should return paginated response",
|
||||
inputRequest: &instance.ListInstancesRequest{
|
||||
Pagination: &filter.PaginationRequest{Offset: 0, Limit: 10},
|
||||
SortingColumn: instance.FieldName_FIELD_NAME_CREATION_DATE,
|
||||
Filters: []*instance.Filter{
|
||||
{
|
||||
Filter: &instance.Filter_InIdsFilter{
|
||||
InIdsFilter: &filter.InIDsFilter{
|
||||
Ids: []string{inst.ID(), inst2.ID()},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
inputContext: ctxWithSysAuthZ,
|
||||
expectedInstances: []string{inst2.ID(), inst.ID()},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tt {
|
||||
t.Run(tc.testName, func(t *testing.T) {
|
||||
// Test
|
||||
res, err := inst.Client.InstanceV2.ListInstances(tc.inputContext, tc.inputRequest)
|
||||
|
||||
// Verify
|
||||
assert.Equal(t, tc.expectedErrorCode, status.Code(err))
|
||||
assert.Equal(t, tc.expectedErrorMsg, status.Convert(err).Message())
|
||||
|
||||
if tc.expectedErrorMsg == "" {
|
||||
require.NotNil(t, res)
|
||||
|
||||
require.Len(t, res.GetInstances(), len(tc.expectedInstances))
|
||||
|
||||
for i, ins := range res.GetInstances() {
|
||||
assert.Equal(t, tc.expectedInstances[i], ins.GetId())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestListCustomDomains(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Given
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
ctxWithSysAuthZ := integration.WithSystemAuthorization(ctx)
|
||||
inst := integration.NewInstance(ctxWithSysAuthZ)
|
||||
|
||||
orgOwnerCtx := inst.WithAuthorizationToken(context.Background(), integration.UserTypeOrgOwner)
|
||||
instanceOwnerCtx := inst.WithAuthorizationToken(context.Background(), integration.UserTypeIAMOwner)
|
||||
d1, d2 := "custom."+integration.DomainName(), "custom."+integration.DomainName()
|
||||
|
||||
_, err := inst.Client.InstanceV2.AddCustomDomain(ctxWithSysAuthZ, &instance.AddCustomDomainRequest{InstanceId: inst.ID(), CustomDomain: d1})
|
||||
require.Nil(t, err)
|
||||
_, err = inst.Client.InstanceV2.AddCustomDomain(ctxWithSysAuthZ, &instance.AddCustomDomainRequest{InstanceId: inst.ID(), CustomDomain: d2})
|
||||
require.Nil(t, err)
|
||||
|
||||
t.Cleanup(func() {
|
||||
inst.Client.InstanceV2.RemoveCustomDomain(ctxWithSysAuthZ, &instance.RemoveCustomDomainRequest{InstanceId: inst.ID(), CustomDomain: d1})
|
||||
inst.Client.InstanceV2.RemoveCustomDomain(ctxWithSysAuthZ, &instance.RemoveCustomDomainRequest{InstanceId: inst.ID(), CustomDomain: d2})
|
||||
inst.Client.InstanceV2.DeleteInstance(ctxWithSysAuthZ, &instance.DeleteInstanceRequest{InstanceId: inst.ID()})
|
||||
})
|
||||
|
||||
tt := []struct {
|
||||
testName string
|
||||
inputRequest *instance.ListCustomDomainsRequest
|
||||
inputContext context.Context
|
||||
expectedErrorMsg string
|
||||
expectedErrorCode codes.Code
|
||||
expectedDomains []string
|
||||
}{
|
||||
{
|
||||
testName: "when invalid context should return unauthN error",
|
||||
inputRequest: &instance.ListCustomDomainsRequest{
|
||||
InstanceId: inst.ID(),
|
||||
Pagination: &filter.PaginationRequest{Offset: 0, Limit: 10},
|
||||
},
|
||||
inputContext: context.Background(),
|
||||
expectedErrorCode: codes.Unauthenticated,
|
||||
expectedErrorMsg: "auth header missing",
|
||||
},
|
||||
{
|
||||
testName: "when unauthZ context should return unauthZ error",
|
||||
inputRequest: &instance.ListCustomDomainsRequest{
|
||||
InstanceId: inst.ID(),
|
||||
Pagination: &filter.PaginationRequest{Offset: 0, Limit: 10},
|
||||
SortingColumn: instance.DomainFieldName_DOMAIN_FIELD_NAME_CREATION_DATE,
|
||||
Filters: []*instance.CustomDomainFilter{
|
||||
{
|
||||
Filter: &instance.CustomDomainFilter_DomainFilter{
|
||||
DomainFilter: &instance.DomainFilter{Domain: "custom", Method: object.TextQueryMethod_TEXT_QUERY_METHOD_CONTAINS},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
inputContext: orgOwnerCtx,
|
||||
expectedErrorCode: codes.NotFound,
|
||||
expectedErrorMsg: "membership not found (AUTHZ-cdgFk)",
|
||||
},
|
||||
{
|
||||
testName: "when valid request with filter should return paginated response (systemUser)",
|
||||
inputRequest: &instance.ListCustomDomainsRequest{
|
||||
InstanceId: inst.ID(),
|
||||
Pagination: &filter.PaginationRequest{Offset: 0, Limit: 10},
|
||||
SortingColumn: instance.DomainFieldName_DOMAIN_FIELD_NAME_CREATION_DATE,
|
||||
Filters: []*instance.CustomDomainFilter{
|
||||
{
|
||||
Filter: &instance.CustomDomainFilter_DomainFilter{
|
||||
DomainFilter: &instance.DomainFilter{Domain: "custom", Method: object.TextQueryMethod_TEXT_QUERY_METHOD_CONTAINS},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
inputContext: ctxWithSysAuthZ,
|
||||
expectedDomains: []string{d1, d2},
|
||||
},
|
||||
{
|
||||
testName: "when valid request with filter should return paginated response (own context)",
|
||||
inputRequest: &instance.ListCustomDomainsRequest{
|
||||
Pagination: &filter.PaginationRequest{Offset: 0, Limit: 10},
|
||||
SortingColumn: instance.DomainFieldName_DOMAIN_FIELD_NAME_CREATION_DATE,
|
||||
Filters: []*instance.CustomDomainFilter{
|
||||
{
|
||||
Filter: &instance.CustomDomainFilter_DomainFilter{
|
||||
DomainFilter: &instance.DomainFilter{Domain: "custom", Method: object.TextQueryMethod_TEXT_QUERY_METHOD_CONTAINS},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
inputContext: instanceOwnerCtx,
|
||||
expectedDomains: []string{d1},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tt {
|
||||
t.Run(tc.testName, func(t *testing.T) {
|
||||
retryDuration, tick := integration.WaitForAndTickWithMaxDuration(tc.inputContext, time.Minute)
|
||||
require.EventuallyWithT(t, func(collect *assert.CollectT) {
|
||||
// Test
|
||||
res, err := inst.Client.InstanceV2.ListCustomDomains(tc.inputContext, tc.inputRequest)
|
||||
|
||||
// Verify
|
||||
assert.Equal(collect, tc.expectedErrorCode, status.Code(err))
|
||||
assert.Equal(collect, tc.expectedErrorMsg, status.Convert(err).Message())
|
||||
|
||||
if tc.expectedErrorMsg == "" {
|
||||
domains := []string{}
|
||||
for _, d := range res.GetDomains() {
|
||||
domains = append(domains, d.GetDomain())
|
||||
}
|
||||
|
||||
assert.Subset(collect, domains, tc.expectedDomains)
|
||||
}
|
||||
}, retryDuration, tick)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestListTrustedDomains(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Given
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
ctxWithSysAuthZ := integration.WithSystemAuthorization(ctx)
|
||||
inst := integration.NewInstance(ctxWithSysAuthZ)
|
||||
|
||||
orgOwnerCtx := inst.WithAuthorizationToken(context.Background(), integration.UserTypeOrgOwner)
|
||||
instanceOwnerCtx := inst.WithAuthorizationToken(context.Background(), integration.UserTypeIAMOwner)
|
||||
d1, d2 := "trusted."+integration.DomainName(), "trusted."+integration.DomainName()
|
||||
|
||||
_, err := inst.Client.InstanceV2.AddTrustedDomain(ctxWithSysAuthZ, &instance.AddTrustedDomainRequest{InstanceId: inst.ID(), TrustedDomain: d1})
|
||||
require.Nil(t, err)
|
||||
_, err = inst.Client.InstanceV2.AddTrustedDomain(ctxWithSysAuthZ, &instance.AddTrustedDomainRequest{InstanceId: inst.ID(), TrustedDomain: d2})
|
||||
require.Nil(t, err)
|
||||
|
||||
t.Cleanup(func() {
|
||||
inst.Client.InstanceV2.RemoveTrustedDomain(ctxWithSysAuthZ, &instance.RemoveTrustedDomainRequest{InstanceId: inst.ID(), TrustedDomain: d1})
|
||||
inst.Client.InstanceV2.RemoveTrustedDomain(ctxWithSysAuthZ, &instance.RemoveTrustedDomainRequest{InstanceId: inst.ID(), TrustedDomain: d2})
|
||||
inst.Client.InstanceV2.DeleteInstance(ctxWithSysAuthZ, &instance.DeleteInstanceRequest{InstanceId: inst.ID()})
|
||||
})
|
||||
|
||||
tt := []struct {
|
||||
testName string
|
||||
inputRequest *instance.ListTrustedDomainsRequest
|
||||
inputContext context.Context
|
||||
expectedErrorMsg string
|
||||
expectedErrorCode codes.Code
|
||||
expectedDomains []string
|
||||
}{
|
||||
{
|
||||
testName: "when invalid context should return unauthN error",
|
||||
inputRequest: &instance.ListTrustedDomainsRequest{
|
||||
InstanceId: inst.ID(),
|
||||
Pagination: &filter.PaginationRequest{Offset: 0, Limit: 10},
|
||||
},
|
||||
inputContext: context.Background(),
|
||||
expectedErrorCode: codes.Unauthenticated,
|
||||
expectedErrorMsg: "auth header missing",
|
||||
},
|
||||
{
|
||||
testName: "when unauthZ context should return unauthZ error",
|
||||
inputRequest: &instance.ListTrustedDomainsRequest{
|
||||
InstanceId: inst.ID(),
|
||||
Pagination: &filter.PaginationRequest{Offset: 0, Limit: 10},
|
||||
},
|
||||
inputContext: orgOwnerCtx,
|
||||
expectedErrorCode: codes.NotFound,
|
||||
expectedErrorMsg: "membership not found (AUTHZ-cdgFk)",
|
||||
},
|
||||
{
|
||||
testName: "when valid request with filter should return paginated response (systemUser)",
|
||||
inputRequest: &instance.ListTrustedDomainsRequest{
|
||||
InstanceId: inst.ID(),
|
||||
Pagination: &filter.PaginationRequest{Offset: 0, Limit: 10},
|
||||
SortingColumn: instance.TrustedDomainFieldName_TRUSTED_DOMAIN_FIELD_NAME_CREATION_DATE,
|
||||
Filters: []*instance.TrustedDomainFilter{
|
||||
{
|
||||
Filter: &instance.TrustedDomainFilter_DomainFilter{
|
||||
DomainFilter: &instance.DomainFilter{Domain: "trusted", Method: object.TextQueryMethod_TEXT_QUERY_METHOD_CONTAINS},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
inputContext: ctxWithSysAuthZ,
|
||||
expectedDomains: []string{d1, d2},
|
||||
},
|
||||
{
|
||||
testName: "when valid request with filter should return paginated response (own context)",
|
||||
inputRequest: &instance.ListTrustedDomainsRequest{
|
||||
Pagination: &filter.PaginationRequest{Offset: 0, Limit: 10},
|
||||
SortingColumn: instance.TrustedDomainFieldName_TRUSTED_DOMAIN_FIELD_NAME_CREATION_DATE,
|
||||
Filters: []*instance.TrustedDomainFilter{
|
||||
{
|
||||
Filter: &instance.TrustedDomainFilter_DomainFilter{
|
||||
DomainFilter: &instance.DomainFilter{Domain: "trusted", Method: object.TextQueryMethod_TEXT_QUERY_METHOD_CONTAINS},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
inputContext: instanceOwnerCtx,
|
||||
expectedDomains: []string{d1},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tt {
|
||||
t.Run(tc.testName, func(t *testing.T) {
|
||||
retryDuration, tick := integration.WaitForAndTickWithMaxDuration(tc.inputContext, time.Minute)
|
||||
require.EventuallyWithT(t, func(collect *assert.CollectT) {
|
||||
// Test
|
||||
res, err := inst.Client.InstanceV2.ListTrustedDomains(tc.inputContext, tc.inputRequest)
|
||||
|
||||
// Verify
|
||||
assert.Equal(collect, tc.expectedErrorCode, status.Code(err))
|
||||
assert.Equal(collect, tc.expectedErrorMsg, status.Convert(err).Message())
|
||||
|
||||
if tc.expectedErrorMsg == "" {
|
||||
require.NotNil(t, res)
|
||||
|
||||
domains := []string{}
|
||||
for _, d := range res.GetTrustedDomain() {
|
||||
domains = append(domains, d.GetDomain())
|
||||
}
|
||||
|
||||
assert.Subset(collect, domains, tc.expectedDomains)
|
||||
}
|
||||
}, retryDuration, tick)
|
||||
})
|
||||
}
|
||||
}
|
||||
84
internal/api/grpc/instance/v2/query.go
Normal file
84
internal/api/grpc/instance/v2/query.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package instance
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"connectrpc.com/connect"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/api/grpc/filter/v2"
|
||||
"github.com/zitadel/zitadel/internal/domain"
|
||||
"github.com/zitadel/zitadel/pkg/grpc/instance/v2"
|
||||
)
|
||||
|
||||
func (s *Server) GetInstance(ctx context.Context, _ *connect.Request[instance.GetInstanceRequest]) (*connect.Response[instance.GetInstanceResponse], error) {
|
||||
if err := s.checkPermission(ctx, domain.PermissionSystemInstanceRead, domain.PermissionInstanceRead); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
inst, err := s.query.Instance(ctx, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return connect.NewResponse(&instance.GetInstanceResponse{
|
||||
Instance: ToProtoObject(inst),
|
||||
}), nil
|
||||
}
|
||||
|
||||
func (s *Server) ListInstances(ctx context.Context, req *connect.Request[instance.ListInstancesRequest]) (*connect.Response[instance.ListInstancesResponse], error) {
|
||||
// List instances is currently only allowed with system permissions,
|
||||
// so we directly check for them in the auth interceptor and do not check here again.
|
||||
queries, err := ListInstancesRequestToModel(req.Msg, s.systemDefaults)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
instances, err := s.query.SearchInstances(ctx, queries)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return connect.NewResponse(&instance.ListInstancesResponse{
|
||||
Instances: InstancesToPb(instances.Instances),
|
||||
Pagination: filter.QueryToPaginationPb(queries.SearchRequest, instances.SearchResponse),
|
||||
}), nil
|
||||
}
|
||||
|
||||
func (s *Server) ListCustomDomains(ctx context.Context, req *connect.Request[instance.ListCustomDomainsRequest]) (*connect.Response[instance.ListCustomDomainsResponse], error) {
|
||||
if err := s.checkPermission(ctx, domain.PermissionSystemInstanceRead, domain.PermissionInstanceRead); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
queries, err := ListCustomDomainsRequestToModel(req.Msg, s.systemDefaults)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
domains, err := s.query.SearchInstanceDomains(ctx, queries)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return connect.NewResponse(&instance.ListCustomDomainsResponse{
|
||||
Domains: DomainsToPb(domains.Domains),
|
||||
Pagination: filter.QueryToPaginationPb(queries.SearchRequest, domains.SearchResponse),
|
||||
}), nil
|
||||
}
|
||||
|
||||
func (s *Server) ListTrustedDomains(ctx context.Context, req *connect.Request[instance.ListTrustedDomainsRequest]) (*connect.Response[instance.ListTrustedDomainsResponse], error) {
|
||||
if err := s.checkPermission(ctx, domain.PermissionSystemInstanceRead, domain.PermissionInstanceRead); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
queries, err := ListTrustedDomainsRequestToModel(req.Msg, s.systemDefaults)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
domains, err := s.query.SearchInstanceTrustedDomains(ctx, queries)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return connect.NewResponse(&instance.ListTrustedDomainsResponse{
|
||||
TrustedDomain: trustedDomainsToPb(domains.Domains),
|
||||
Pagination: filter.QueryToPaginationPb(queries.SearchRequest, domains.SearchResponse),
|
||||
}), nil
|
||||
}
|
||||
74
internal/api/grpc/instance/v2/server.go
Normal file
74
internal/api/grpc/instance/v2/server.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package instance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"connectrpc.com/connect"
|
||||
"google.golang.org/protobuf/reflect/protoreflect"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/api/authz"
|
||||
"github.com/zitadel/zitadel/internal/command"
|
||||
"github.com/zitadel/zitadel/internal/config/systemdefaults"
|
||||
"github.com/zitadel/zitadel/internal/domain"
|
||||
"github.com/zitadel/zitadel/internal/query"
|
||||
"github.com/zitadel/zitadel/pkg/grpc/instance/v2"
|
||||
"github.com/zitadel/zitadel/pkg/grpc/instance/v2/instanceconnect"
|
||||
)
|
||||
|
||||
var _ instanceconnect.InstanceServiceHandler = (*Server)(nil)
|
||||
|
||||
type Server struct {
|
||||
command *command.Commands
|
||||
query *query.Queries
|
||||
systemDefaults systemdefaults.SystemDefaults
|
||||
defaultInstance command.InstanceSetup
|
||||
externalDomain string
|
||||
permissionCheck domain.PermissionCheck
|
||||
}
|
||||
|
||||
func CreateServer(
|
||||
command *command.Commands,
|
||||
query *query.Queries,
|
||||
defaultInstance command.InstanceSetup,
|
||||
externalDomain string,
|
||||
check domain.PermissionCheck,
|
||||
) *Server {
|
||||
return &Server{
|
||||
command: command,
|
||||
query: query,
|
||||
defaultInstance: defaultInstance,
|
||||
externalDomain: externalDomain,
|
||||
permissionCheck: check,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) RegisterConnectServer(interceptors ...connect.Interceptor) (string, http.Handler) {
|
||||
return instanceconnect.NewInstanceServiceHandler(s, connect.WithInterceptors(interceptors...))
|
||||
}
|
||||
|
||||
func (s *Server) FileDescriptor() protoreflect.FileDescriptor {
|
||||
return instance.File_zitadel_instance_v2_instance_service_proto
|
||||
}
|
||||
|
||||
func (s *Server) AppName() string {
|
||||
return instance.InstanceService_ServiceDesc.ServiceName
|
||||
}
|
||||
|
||||
func (s *Server) MethodPrefix() string {
|
||||
return instance.InstanceService_ServiceDesc.ServiceName
|
||||
}
|
||||
|
||||
func (s *Server) AuthMethods() authz.MethodMapping {
|
||||
return instance.InstanceService_AuthMethods
|
||||
}
|
||||
|
||||
// checkPermission checks if either the system-wide or the instance-specific permission is granted.
|
||||
func (s *Server) checkPermission(ctx context.Context, systemPermission, instancePermission string) error {
|
||||
// Let's first check the system permission since it's already resolved into the context.
|
||||
// If that succeeds, we don't need to resolve the instance permission.
|
||||
if err := s.permissionCheck(ctx, systemPermission, "", ""); err == nil {
|
||||
return nil
|
||||
}
|
||||
return s.permissionCheck(ctx, instancePermission, "", "")
|
||||
}
|
||||
@@ -40,7 +40,10 @@ func setInstance(ctx context.Context, req connect.AnyRequest, handler connect.Un
|
||||
if !ok {
|
||||
return handler(ctx, req)
|
||||
}
|
||||
return addInstanceByID(interceptorCtx, req, handler, verifier, translator, withInstanceIDProperty.GetInstanceId())
|
||||
instanceID := withInstanceIDProperty.GetInstanceId()
|
||||
if instanceID != "" {
|
||||
return addInstanceByID(interceptorCtx, req, handler, verifier, translator, instanceID)
|
||||
}
|
||||
}
|
||||
}
|
||||
explicitInstanceRequest, ok := req.Any().(interface {
|
||||
@@ -61,11 +64,12 @@ func setInstance(ctx context.Context, req connect.AnyRequest, handler connect.Un
|
||||
func addInstanceByID(ctx context.Context, req connect.AnyRequest, handler connect.UnaryFunc, verifier authz.InstanceVerifier, translator *i18n.Translator, id string) (connect.AnyResponse, error) {
|
||||
instance, err := verifier.InstanceByID(ctx, id)
|
||||
if err != nil {
|
||||
notFoundErr := new(zerrors.ZitadelError)
|
||||
if errors.As(err, ¬FoundErr) {
|
||||
notFoundErr.Message = translator.LocalizeFromCtx(ctx, notFoundErr.GetMessage(), nil)
|
||||
}
|
||||
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("unable to set instance using id %s: %w", id, notFoundErr))
|
||||
// We do not want to expose whether the instance id was invalid or not
|
||||
// to prevent leaking information about existing instances.
|
||||
// In case the user has permission to access the instance, but the instance does not exist,
|
||||
// the error will be returned by the business logic later on.
|
||||
logging.WithFields("instanceID", id).WithError(err).Error("unable to set instance by id")
|
||||
return handler(ctx, req)
|
||||
}
|
||||
return handler(authz.WithInstance(ctx, instance), req)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user