diff --git a/internal/api/grpc/object/v2beta/converter.go b/internal/api/grpc/object/v2beta/converter.go index 9b14bb677a..74d1c119e3 100644 --- a/internal/api/grpc/object/v2beta/converter.go +++ b/internal/api/grpc/object/v2beta/converter.go @@ -9,6 +9,7 @@ import ( "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/query" object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta" + // object_pb "github.com/zitadel/zitadel/pkg/grpc/org/v2beta" ) func DomainToDetailsPb(objectDetail *domain.ObjectDetails) *object.Details { @@ -34,6 +35,7 @@ func ToListDetails(response query.SearchResponse) *object.ListDetails { return details } + func ListQueryToQuery(query *object.ListQuery) (offset, limit uint64, asc bool) { if query == nil { return 0, 0, false @@ -73,3 +75,10 @@ func TextMethodToQuery(method object.TextQueryMethod) query.TextComparison { return -1 } } + +func ListQueryToModel(query *object.ListQuery) (offset, limit uint64, asc bool) { + if query == nil { + return 0, 0, false + } + return query.Offset, uint64(query.Limit), query.Asc +} diff --git a/internal/api/grpc/org/v2beta/helper.go b/internal/api/grpc/org/v2beta/helper.go index 9d28c5d7a5..a43aff45e2 100644 --- a/internal/api/grpc/org/v2beta/helper.go +++ b/internal/api/grpc/org/v2beta/helper.go @@ -3,11 +3,13 @@ package org import ( "time" - object "github.com/zitadel/zitadel/internal/api/grpc/object/v2beta" + v2beta_object "github.com/zitadel/zitadel/internal/api/grpc/object/v2beta" "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/internal/zerrors" org "github.com/zitadel/zitadel/pkg/grpc/org/v2beta" + v2beta_org "github.com/zitadel/zitadel/pkg/grpc/org/v2beta" v2beta "github.com/zitadel/zitadel/pkg/grpc/object/v2beta" @@ -15,6 +17,27 @@ import ( "google.golang.org/protobuf/types/known/timestamppb" ) +// NOTE: most of this code is copied from `internal/api/grpc/admin/*`, as we will eventually axe the previous versons of the API, +// we will have code duplication until then + +func listOrgRequestToModel(request *v2beta_org.ListOrganizationsRequest) (*query.OrgSearchQueries, error) { + offset, limit, asc := v2beta_object.ListQueryToModel(request.Query) + // queries, err := org_pb.OrgQueriesToModel(request.Queries) + queries, err := OrgQueriesToModel(request.Queries) + if err != nil { + return nil, err + } + return &query.OrgSearchQueries{ + SearchRequest: query.SearchRequest{ + Offset: offset, + Limit: limit, + SortingColumn: FieldNameToOrgColumn(request.SortingColumn), + Asc: asc, + }, + Queries: queries, + }, nil +} + func OrganizationViewToPb(org *query.Org) *org_pb.Organization { return &org_pb.Organization{ Id: org.ID, @@ -70,8 +93,81 @@ func createdOrganizationToPb(createdOrg *command.CreatedOrg) (_ *org.CreateOrgan } } return &org.CreateOrganizationResponse{ - Details: object.DomainToDetailsPb(createdOrg.ObjectDetails), + Details: v2beta_object.DomainToDetailsPb(createdOrg.ObjectDetails), OrganizationId: createdOrg.ObjectDetails.ResourceOwner, CreatedAdmins: admins, }, nil } + +func OrgViewsToPb(orgs []*query.Org) []*org_pb.Organization { + o := make([]*org_pb.Organization, len(orgs)) + for i, org := range orgs { + o[i] = OrgViewToPb(org) + } + return o +} + +func OrgQueriesToModel(queries []*org_pb.OrgQuery) (_ []query.SearchQuery, err error) { + q := make([]query.SearchQuery, len(queries)) + for i, query := range queries { + q[i], err = OrgQueryToModel(query) + if err != nil { + return nil, err + } + } + return q, nil +} + +func OrgQueryToModel(apiQuery *org_pb.OrgQuery) (query.SearchQuery, error) { + switch q := apiQuery.Query.(type) { + case *org_pb.OrgQuery_DomainQuery: + return query.NewOrgVerifiedDomainSearchQuery(v2beta_object.TextMethodToQuery(q.DomainQuery.Method), q.DomainQuery.Domain) + case *org_pb.OrgQuery_NameQuery: + return query.NewOrgNameSearchQuery(v2beta_object.TextMethodToQuery(q.NameQuery.Method), q.NameQuery.Name) + case *org_pb.OrgQuery_StateQuery: + return query.NewOrgStateSearchQuery(OrgStateToDomain(q.StateQuery.State)) + case *org_pb.OrgQuery_IdQuery: + return query.NewOrgIDSearchQuery(q.IdQuery.Id) + default: + return nil, zerrors.ThrowInvalidArgument(nil, "ORG-vR9nC", "List.Query.Invalid") + } +} + +func OrgStateToDomain(state org_pb.OrgState) domain.OrgState { + switch state { + case org_pb.OrgState_ORG_STATE_ACTIVE: + return domain.OrgStateActive + case org_pb.OrgState_ORG_STATE_INACTIVE: + return domain.OrgStateInactive + case org_pb.OrgState_ORG_STATE_UNSPECIFIED: + fallthrough + default: + return domain.OrgStateUnspecified + } +} + +func FieldNameToOrgColumn(fieldName org_pb.OrgFieldName) query.Column { + switch fieldName { + case org_pb.OrgFieldName_ORG_FIELD_NAME_NAME: + return query.OrgColumnName + case org_pb.OrgFieldName_ORG_FIELD_NAME_UNSPECIFIED: + return query.Column{} + default: + return query.Column{} + } +} + +func OrgViewToPb(org *query.Org) *org_pb.Organization { + return &org_pb.Organization{ + Id: org.ID, + State: OrgStateToPb(org.State), + Name: org.Name, + PrimaryDomain: org.Domain, + Details: ToViewDetailsPb( + org.Sequence, + org.CreationDate, + org.ChangeDate, + org.ResourceOwner, + ), + } +} diff --git a/internal/api/grpc/org/v2beta/integration_test/org_test.go b/internal/api/grpc/org/v2beta/integration_test/org_test.go index 5f46d51697..11438fcdb1 100644 --- a/internal/api/grpc/org/v2beta/integration_test/org_test.go +++ b/internal/api/grpc/org/v2beta/integration_test/org_test.go @@ -4,6 +4,7 @@ package org_test import ( "context" + "fmt" "os" "testing" "time" @@ -41,11 +42,12 @@ func TestMain(m *testing.M) { } func TestServer_GetOrganizationByID(t *testing.T) { - orgName := gofakeit.Name() - orgId, err := createOrg(orgName) + orgs, orgsName, err := createOrgs(1) if err != nil { assert.Fail(t, "unable to create org") } + orgId := orgs[0].OrganizationId + orgName := orgsName[0] tests := []struct { name string @@ -233,11 +235,12 @@ func TestServer_CreateOrganization(t *testing.T) { } func TestServer_UpdateOrganization(t *testing.T) { - orgName := gofakeit.Name() - orgId, err := createOrg(orgName) + orgs, orgsName, err := createOrgs(1) if err != nil { assert.Fail(t, "unable to create org") } + orgId := orgs[0].OrganizationId + orgName := orgsName[0] tests := []struct { name string @@ -291,17 +294,95 @@ func TestServer_UpdateOrganization(t *testing.T) { } } -func createOrg(orgName string) (string, error) { - org, err := Client.CreateOrganization(CTX, - &org.CreateOrganizationRequest{ - Name: orgName, - }, - ) +// TODO: finish off qyery testing in ListOrganizations +func TestServer_ListOrganization(t *testing.T) { + noOfOrgs := 3 + orgs, orgsName, err := createOrgs(noOfOrgs) if err != nil { - return "", err + assert.Fail(t, "unable to create org") } - return org.OrganizationId, nil + tests := []struct { + name string + ctx context.Context + req *org.ListOrganizationsRequest + want []*org.Organization + wantErr bool + }{ + { + name: "update org with same name", + ctx: Instance.WithAuthorization(context.Background(), integration.UserTypeOrgOwner), + req: &org.ListOrganizationsRequest{}, + want: []*org.Organization{ + { + Id: orgs[0].OrganizationId, + Name: orgsName[0], + }, + { + Id: orgs[1].OrganizationId, + Name: orgsName[1], + }, + { + Id: orgs[2].OrganizationId, + Name: orgsName[2], + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(context.Background(), 10*time.Minute) + require.EventuallyWithT(t, func(ttt *assert.CollectT) { + got, err := Client.ListOrganizations(tt.ctx, tt.req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + // require.Equal(t, len(tt.want), len(got.Result)) + + // check details + // assert.NotZero(t, got.GetDetails().GetSequence()) + // gotCD := got.GetDetails().GetChangeDate().AsTime() + // now := time.Now() + // assert.WithinRange(t, gotCD, now.Add(-time.Minute), now.Add(time.Minute)) + // assert.NotEmpty(t, got.GetDetails().GetResourceOwner()) + + foundOrgs := 0 + for _, got := range got.Result { + for _, org := range tt.want { + if org.Name == got.Name && + org.Id == got.Id { + foundOrgs += 1 + } + // require.Equal(t, org.do, got.Result[i].Name) + } + } + fmt.Printf("@@ >>>>>>>>>>>>>>>>>>>>>>>>>>>> foundOrgs = %+v\n", foundOrgs) + require.Equal(t, len(tt.want), foundOrgs) + }, retryDuration, tick, "timeout waiting for expected organizations being created") + }) + } +} + +func createOrgs(noOfOrgs int) ([]*org.CreateOrganizationResponse, []string, error) { + var err error + orgs := make([]*org.CreateOrganizationResponse, noOfOrgs) + orgsName := make([]string, noOfOrgs) + for i := range noOfOrgs { + orgName := gofakeit.Name() + orgsName[i] = orgName + orgs[i], err = Client.CreateOrganization(CTX, + &org.CreateOrganizationRequest{ + Name: orgName, + }, + ) + if err != nil { + return nil, nil, err + } + } + + return orgs, orgsName, nil } func assertCreatedAdmin(t *testing.T, expected, got *org.CreateOrganizationResponse_CreatedAdmin) { diff --git a/internal/api/grpc/org/v2beta/org.go b/internal/api/grpc/org/v2beta/org.go index 2dead655a8..a2ffb35a3d 100644 --- a/internal/api/grpc/org/v2beta/org.go +++ b/internal/api/grpc/org/v2beta/org.go @@ -43,6 +43,21 @@ func (s *Server) GetOrganizationByID(ctx context.Context, request *v2beta_org.Ge }, nil } +func (s *Server) ListOrganizations(ctx context.Context, request *v2beta_org.ListOrganizationsRequest) (*v2beta_org.ListOrganizationsResponse, error) { + queries, err := listOrgRequestToModel(request) + if err != nil { + return nil, err + } + orgs, err := s.query.SearchOrgs(ctx, queries, nil) + if err != nil { + return nil, err + } + return &v2beta_org.ListOrganizationsResponse{ + Result: OrgViewsToPb(orgs.Orgs), + Details: object.ToListDetails(orgs.SearchResponse), + }, nil +} + func createOrganizationRequestToCommand(request *v2beta_org.CreateOrganizationRequest) (*command.OrgSetup, error) { admins, err := createOrganizationRequestAdminsToCommand(request.GetAdmins()) if err != nil { diff --git a/proto/zitadel/org/v2beta/org_service.proto b/proto/zitadel/org/v2beta/org_service.proto index bd51e53952..cd745c6dca 100644 --- a/proto/zitadel/org/v2beta/org_service.proto +++ b/proto/zitadel/org/v2beta/org_service.proto @@ -7,15 +7,9 @@ import "zitadel/object/v2beta/object.proto"; import "zitadel/protoc_gen_zitadel/v2/options.proto"; import "zitadel/user/v2beta/auth.proto"; import "zitadel/org/v2beta/org.proto"; -// import "zitadel/user/v2beta/email.proto"; -// import "zitadel/user/v2beta/phone.proto"; -// import "zitadel/user/v2beta/idp.proto"; -// import "zitadel/user/v2beta/password.proto"; -import "zitadel/user/v2beta/user.proto"; import "zitadel/user/v2beta/user_service.proto"; import "google/api/annotations.proto"; import "google/api/field_behavior.proto"; -import "google/protobuf/duration.proto"; import "google/protobuf/struct.proto"; import "protoc-gen-openapiv2/options/annotations.proto"; import "validate/validate.proto"; @@ -175,7 +169,7 @@ service OrganizationService { option (zitadel.protoc_gen_zitadel.v2.options) = { auth_option: { - permission: "org.read"; + permission: "iam.read"; } http_response: { success_code: 200 @@ -195,6 +189,83 @@ service OrganizationService { }; } + rpc ListOrganizations(ListOrganizationsRequest) returns (ListOrganizationsResponse) { + option (google.api.http) = { + post: "/v2beta/organizations/_search"; + body: "*"; + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "iam.read"; + } + http_response: { + success_code: 200 + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + tags: "Organizations"; + summary: "Search Organization"; + description: "Returns a list of organizations that match the requesting filters. All filters are applied with an AND condition." + responses: { + key: "200"; + value: { + description: "list of organizations matching the query"; + }; + }; + responses: { + key: "400"; + value: { + description: "invalid list query"; + schema: { + json_schema: { + ref: "#/definitions/rpcStatus"; + }; + }; + }; + }; + }; + } + + rpc DeleteOrganization(DeleteOrganizationRequest) returns (DeleteOrganizationResponse) { + option (google.api.http) = { + delete: "/v2beta/organizations" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "iam.write"; + } + http_response: { + success_code: 200 + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + tags: "Organizations"; + summary: "Remove Organization"; + description: "Deletes the organization and all its resources (Users, Projects, Grants to and from the org). Users of this organization will not be able to log in." + responses: { + key: "200"; + value: { + description: "org removed successfully"; + }; + }; + responses: { + key: "400"; + value: { + description: "invalid org"; + schema: { + json_schema: { + ref: "#/definitions/rpcStatus"; + }; + }; + }; + }; + }; + } + } message CreateOrganizationRequest{ @@ -273,3 +344,49 @@ message GetOrganizationByIDRequest { message GetOrganizationByIDResponse { zitadel.org.v2beta.Organization organization = 1; } + +message ListOrganizationsRequest { + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_schema) = { + json_schema: { + description: "Search query for lists"; + required: ["query"] + }; + }; + + //list limitations and ordering + // zitadel.v1.ListQuery query = 1; + zitadel.object.v2beta.ListQuery query = 1; + // the field the result is sorted + // zitadel.org.v1.OrgFieldName sorting_column = 2; + zitadel.org.v2beta.OrgFieldName sorting_column = 2; + //criteria the client is looking for + repeated zitadel.org.v2beta.OrgQuery queries = 3; +} + +message ListOrganizationsResponse { + zitadel.object.v2beta.ListDetails details = 1; + // zitadel.org.v2beta.Organization organization = 1; + zitadel.org.v2beta.OrgFieldName sorting_column = 2; + repeated zitadel.org.v2beta.Organization result = 3; +} + +message DeleteOrganizationRequest { + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_schema) = { + json_schema: { + required: ["org_id"] + }; + }; + + string org_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"69629023906488334\""; + min_length: 1; + max_length: 200; + } + ]; +} + +message DeleteOrganizationResponse { + zitadel.object.v2beta.Details details = 1; +}