diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js index 3aad64d9e..a07f6e8c7 100644 --- a/docs/docusaurus.config.js +++ b/docs/docusaurus.config.js @@ -356,6 +356,14 @@ module.exports = { categoryLinkSource: "auto", }, }, + org_v2: { + specPath: ".artifacts/openapi/zitadel/org/v2/org_service.swagger.json", + outputDir: "docs/apis/resources/org_service_v2", + sidebarOptions: { + groupPathsBy: "tag", + categoryLinkSource: "auto", + }, + }, idp_v2: { specPath: ".artifacts/openapi/zitadel/idp/v2/idp_service.swagger.json", outputDir: "docs/apis/resources/idp_service_v2", diff --git a/docs/sidebars.js b/docs/sidebars.js index 98667395f..ffa910b54 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -679,6 +679,18 @@ module.exports = { }, items: require("./docs/apis/resources/feature_service_v2/sidebar.ts"), }, + { + type: "category", + label: "Organization Lifecycle", + link: { + type: "generated-index", + title: "Organization Service API", + slug: "/apis/resources/org_service/v2", + description: + 'This API is intended to manage organizations for ZITADEL. \n' + }, + items: require("./docs/apis/resources/org_service_v2/sidebar.ts"), + }, { type: "category", label: "Identity Provider Lifecycle", diff --git a/internal/api/grpc/admin/export.go b/internal/api/grpc/admin/export.go index 9a8f075c7..8394ae78d 100644 --- a/internal/api/grpc/admin/export.go +++ b/internal/api/grpc/admin/export.go @@ -36,7 +36,7 @@ func (s *Server) ExportData(ctx context.Context, req *admin_pb.ExportDataRequest } orgSearchQuery.Queries = []query.SearchQuery{orgIDsSearchQuery} } - queriedOrgs, err := s.query.SearchOrgs(ctx, orgSearchQuery) + queriedOrgs, err := s.query.SearchOrgs(ctx, orgSearchQuery, nil) if err != nil { return nil, err } @@ -554,7 +554,7 @@ func (s *Server) getUsers(ctx context.Context, org string, withPasswords bool, w if err != nil { return nil, nil, nil, nil, err } - users, err := s.query.SearchUsers(ctx, &query.UserSearchQueries{Queries: []query.SearchQuery{orgSearch}}) + users, err := s.query.SearchUsers(ctx, &query.UserSearchQueries{Queries: []query.SearchQuery{orgSearch}}, nil) if err != nil { return nil, nil, nil, nil, err } diff --git a/internal/api/grpc/admin/org.go b/internal/api/grpc/admin/org.go index 81064cebf..934de1b57 100644 --- a/internal/api/grpc/admin/org.go +++ b/internal/api/grpc/admin/org.go @@ -59,7 +59,7 @@ func (s *Server) ListOrgs(ctx context.Context, req *admin_pb.ListOrgsRequest) (* if err != nil { return nil, err } - orgs, err := s.query.SearchOrgs(ctx, queries) + orgs, err := s.query.SearchOrgs(ctx, queries, nil) if err != nil { return nil, err } @@ -108,7 +108,7 @@ func (s *Server) getClaimedUserIDsOfOrgDomain(ctx context.Context, orgDomain str if err != nil { return nil, err } - users, err := s.query.SearchUsers(ctx, &query.UserSearchQueries{Queries: []query.SearchQuery{loginName}}) + users, err := s.query.SearchUsers(ctx, &query.UserSearchQueries{Queries: []query.SearchQuery{loginName}}, nil) if err != nil { return nil, err } diff --git a/internal/api/grpc/auth/user.go b/internal/api/grpc/auth/user.go index 7c874bf76..90e0ddc1d 100644 --- a/internal/api/grpc/auth/user.go +++ b/internal/api/grpc/auth/user.go @@ -220,7 +220,7 @@ func (s *Server) ListMyProjectOrgs(ctx context.Context, req *auth_pb.ListMyProje } } - orgs, err := s.query.SearchOrgs(ctx, queries) + orgs, err := s.query.SearchOrgs(ctx, queries, nil) if err != nil { return nil, err } diff --git a/internal/api/grpc/management/org.go b/internal/api/grpc/management/org.go index 91ef8e3b8..d25d46d85 100644 --- a/internal/api/grpc/management/org.go +++ b/internal/api/grpc/management/org.go @@ -330,7 +330,7 @@ func (s *Server) getClaimedUserIDsOfOrgDomain(ctx context.Context, orgDomain, or } queries = append(queries, owner) } - users, err := s.query.SearchUsers(ctx, &query.UserSearchQueries{Queries: queries}) + users, err := s.query.SearchUsers(ctx, &query.UserSearchQueries{Queries: queries}, nil) if err != nil { return nil, err } diff --git a/internal/api/grpc/management/user.go b/internal/api/grpc/management/user.go index 64d99ea78..981e7823a 100644 --- a/internal/api/grpc/management/user.go +++ b/internal/api/grpc/management/user.go @@ -68,7 +68,7 @@ func (s *Server) ListUsers(ctx context.Context, req *mgmt_pb.ListUsersRequest) ( if err != nil { return nil, err } - res, err := s.query.SearchUsers(ctx, queries) + res, err := s.query.SearchUsers(ctx, queries, nil) if err != nil { return nil, err } diff --git a/internal/api/grpc/org/v2/org_integration_test.go b/internal/api/grpc/org/v2/org_integration_test.go index 9f3f9fa64..2005ea84b 100644 --- a/internal/api/grpc/org/v2/org_integration_test.go +++ b/internal/api/grpc/org/v2/org_integration_test.go @@ -19,22 +19,26 @@ import ( ) var ( - CTX context.Context - Tester *integration.Tester - Client org.OrganizationServiceClient - User *user.AddHumanUserResponse + CTX context.Context + OwnerCTX context.Context + UserCTX context.Context + Tester *integration.Tester + Client org.OrganizationServiceClient + User *user.AddHumanUserResponse ) func TestMain(m *testing.M) { os.Exit(func() int { - ctx, errCtx, cancel := integration.Contexts(5 * time.Minute) + ctx, _, cancel := integration.Contexts(5 * time.Minute) defer cancel() Tester = integration.NewTester(ctx) defer Tester.Done() Client = Tester.Client.OrgV2 - CTX, _ = Tester.WithAuthorization(ctx, integration.IAMOwner), errCtx + CTX = Tester.WithAuthorization(ctx, integration.IAMOwner) + OwnerCTX = Tester.WithAuthorization(ctx, integration.OrgOwner) + UserCTX = Tester.WithAuthorization(ctx, integration.Login) User = Tester.CreateHumanUser(CTX) return m.Run() }()) diff --git a/internal/api/grpc/org/v2/query.go b/internal/api/grpc/org/v2/query.go new file mode 100644 index 000000000..772726fd4 --- /dev/null +++ b/internal/api/grpc/org/v2/query.go @@ -0,0 +1,132 @@ +package org + +import ( + "context" + + "github.com/zitadel/zitadel/internal/api/grpc/object/v2" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/internal/zerrors" + "github.com/zitadel/zitadel/pkg/grpc/org/v2" +) + +func (s *Server) ListOrganizations(ctx context.Context, req *org.ListOrganizationsRequest) (*org.ListOrganizationsResponse, error) { + queries, err := listOrgRequestToModel(req) + if err != nil { + return nil, err + } + orgs, err := s.query.SearchOrgs(ctx, queries, s.checkPermission) + if err != nil { + return nil, err + } + return &org.ListOrganizationsResponse{ + Result: organizationsToPb(orgs.Orgs), + Details: object.ToListDetails(orgs.SearchResponse), + }, nil +} + +func listOrgRequestToModel(req *org.ListOrganizationsRequest) (*query.OrgSearchQueries, error) { + offset, limit, asc := object.ListQueryToQuery(req.Query) + queries, err := orgQueriesToQuery(req.Queries) + if err != nil { + return nil, err + } + return &query.OrgSearchQueries{ + SearchRequest: query.SearchRequest{ + Offset: offset, + Limit: limit, + SortingColumn: fieldNameToOrganizationColumn(req.SortingColumn), + Asc: asc, + }, + Queries: queries, + }, nil +} + +func orgQueriesToQuery(queries []*org.SearchQuery) (_ []query.SearchQuery, err error) { + q := make([]query.SearchQuery, len(queries)) + for i, query := range queries { + q[i], err = orgQueryToQuery(query) + if err != nil { + return nil, err + } + } + return q, nil +} + +func orgQueryToQuery(orgQuery *org.SearchQuery) (query.SearchQuery, error) { + switch q := orgQuery.Query.(type) { + case *org.SearchQuery_DomainQuery: + return query.NewOrgDomainSearchQuery(object.TextMethodToQuery(q.DomainQuery.Method), q.DomainQuery.Domain) + case *org.SearchQuery_NameQuery: + return query.NewOrgNameSearchQuery(object.TextMethodToQuery(q.NameQuery.Method), q.NameQuery.Name) + case *org.SearchQuery_StateQuery: + return query.NewOrgStateSearchQuery(orgStateToDomain(q.StateQuery.State)) + case *org.SearchQuery_IdQuery: + return query.NewOrgIDSearchQuery(q.IdQuery.Id) + default: + return nil, zerrors.ThrowInvalidArgument(nil, "ORG-vR9nC", "List.Query.Invalid") + } +} + +func orgStateToPb(state domain.OrgState) org.OrganizationState { + switch state { + case domain.OrgStateActive: + return org.OrganizationState_ORGANIZATION_STATE_ACTIVE + case domain.OrgStateInactive: + return org.OrganizationState_ORGANIZATION_STATE_INACTIVE + case domain.OrgStateRemoved: + return org.OrganizationState_ORGANIZATION_STATE_REMOVED + case domain.OrgStateUnspecified: + fallthrough + default: + return org.OrganizationState_ORGANIZATION_STATE_UNSPECIFIED + } +} + +func orgStateToDomain(state org.OrganizationState) domain.OrgState { + switch state { + case org.OrganizationState_ORGANIZATION_STATE_ACTIVE: + return domain.OrgStateActive + case org.OrganizationState_ORGANIZATION_STATE_INACTIVE: + return domain.OrgStateInactive + case org.OrganizationState_ORGANIZATION_STATE_REMOVED: + return domain.OrgStateRemoved + case org.OrganizationState_ORGANIZATION_STATE_UNSPECIFIED: + fallthrough + default: + return domain.OrgStateUnspecified + } +} + +func fieldNameToOrganizationColumn(fieldName org.OrganizationFieldName) query.Column { + switch fieldName { + case org.OrganizationFieldName_ORGANIZATION_FIELD_NAME_NAME: + return query.OrgColumnName + case org.OrganizationFieldName_ORGANIZATION_FIELD_NAME_UNSPECIFIED: + return query.Column{} + default: + return query.Column{} + } +} + +func organizationsToPb(orgs []*query.Org) []*org.Organization { + o := make([]*org.Organization, len(orgs)) + for i, org := range orgs { + o[i] = organizationToPb(org) + } + return o +} + +func organizationToPb(organization *query.Org) *org.Organization { + return &org.Organization{ + Id: organization.ID, + Name: organization.Name, + PrimaryDomain: organization.Domain, + Details: object.DomainToDetailsPb(&domain.ObjectDetails{ + Sequence: organization.Sequence, + EventDate: organization.ChangeDate, + ResourceOwner: organization.ResourceOwner, + }), + State: orgStateToPb(organization.State), + } +} diff --git a/internal/api/grpc/org/v2/query_integration_test.go b/internal/api/grpc/org/v2/query_integration_test.go new file mode 100644 index 000000000..5110b8652 --- /dev/null +++ b/internal/api/grpc/org/v2/query_integration_test.go @@ -0,0 +1,443 @@ +//go:build integration + +package org_test + +import ( + "context" + "fmt" + "strconv" + "testing" + "time" + + "github.com/brianvoe/gofakeit/v6" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/integration" + "github.com/zitadel/zitadel/pkg/grpc/object/v2" + "github.com/zitadel/zitadel/pkg/grpc/org/v2" +) + +type orgAttr struct { + ID string + Name string + Details *object.Details +} + +func TestServer_ListOrganizations(t *testing.T) { + type args struct { + ctx context.Context + req *org.ListOrganizationsRequest + dep func(ctx context.Context, request *org.ListOrganizationsRequest) ([]orgAttr, error) + } + tests := []struct { + name string + args args + want *org.ListOrganizationsResponse + wantErr bool + }{ + { + name: "list org by id, ok, multiple", + args: args{ + CTX, + &org.ListOrganizationsRequest{ + Queries: []*org.SearchQuery{ + OrganizationIdQuery(Tester.Organisation.ID), + }, + }, + func(ctx context.Context, request *org.ListOrganizationsRequest) ([]orgAttr, error) { + count := 3 + orgs := make([]orgAttr, count) + prefix := fmt.Sprintf("ListOrgs%d", time.Now().UnixNano()) + for i := 0; i < count; i++ { + name := prefix + strconv.Itoa(i) + orgResp := Tester.CreateOrganization(ctx, name, fmt.Sprintf("%d@mouse.com", time.Now().UnixNano())) + orgs[i] = orgAttr{ + ID: orgResp.GetOrganizationId(), + Name: name, + Details: orgResp.GetDetails(), + } + } + request.Queries = []*org.SearchQuery{ + OrganizationNamePrefixQuery(prefix), + } + return orgs, nil + }, + }, + want: &org.ListOrganizationsResponse{ + Details: &object.ListDetails{ + TotalResult: 3, + Timestamp: timestamppb.Now(), + }, + SortingColumn: 0, + Result: []*org.Organization{ + { + State: org.OrganizationState_ORGANIZATION_STATE_ACTIVE, + }, + { + State: org.OrganizationState_ORGANIZATION_STATE_ACTIVE, + }, + { + State: org.OrganizationState_ORGANIZATION_STATE_ACTIVE, + }, + }, + }, + }, + { + name: "list org by id, ok", + args: args{ + CTX, + &org.ListOrganizationsRequest{ + Queries: []*org.SearchQuery{ + OrganizationIdQuery(Tester.Organisation.ID), + }, + }, + nil, + }, + want: &org.ListOrganizationsResponse{ + Details: &object.ListDetails{ + TotalResult: 1, + Timestamp: timestamppb.Now(), + }, + SortingColumn: 0, + Result: []*org.Organization{ + { + State: org.OrganizationState_ORGANIZATION_STATE_ACTIVE, + Name: Tester.Organisation.Name, + Details: &object.Details{ + Sequence: Tester.Organisation.Sequence, + ChangeDate: timestamppb.New(Tester.Organisation.ChangeDate), + ResourceOwner: Tester.Organisation.ResourceOwner, + }, + Id: Tester.Organisation.ID, + PrimaryDomain: Tester.Organisation.Domain, + }, + }, + }, + }, + { + name: "list org by name, ok", + args: args{ + CTX, + &org.ListOrganizationsRequest{ + Queries: []*org.SearchQuery{ + OrganizationNameQuery(Tester.Organisation.Name), + }, + }, + nil, + }, + want: &org.ListOrganizationsResponse{ + Details: &object.ListDetails{ + TotalResult: 1, + Timestamp: timestamppb.Now(), + }, + SortingColumn: 0, + Result: []*org.Organization{ + { + State: org.OrganizationState_ORGANIZATION_STATE_ACTIVE, + Name: Tester.Organisation.Name, + Details: &object.Details{ + Sequence: Tester.Organisation.Sequence, + ChangeDate: timestamppb.New(Tester.Organisation.ChangeDate), + ResourceOwner: Tester.Organisation.ResourceOwner, + }, + Id: Tester.Organisation.ID, + PrimaryDomain: Tester.Organisation.Domain, + }, + }, + }, + }, + { + name: "list org by domain, ok", + args: args{ + CTX, + &org.ListOrganizationsRequest{ + Queries: []*org.SearchQuery{ + OrganizationDomainQuery(Tester.Organisation.Domain), + }, + }, + nil, + }, + want: &org.ListOrganizationsResponse{ + Details: &object.ListDetails{ + TotalResult: 1, + Timestamp: timestamppb.Now(), + }, + SortingColumn: 0, + Result: []*org.Organization{ + { + State: org.OrganizationState_ORGANIZATION_STATE_ACTIVE, + Name: Tester.Organisation.Name, + Details: &object.Details{ + Sequence: Tester.Organisation.Sequence, + ChangeDate: timestamppb.New(Tester.Organisation.ChangeDate), + ResourceOwner: Tester.Organisation.ResourceOwner, + }, + Id: Tester.Organisation.ID, + PrimaryDomain: Tester.Organisation.Domain, + }, + }, + }, + }, + { + name: "list org by inactive state, ok", + args: args{ + CTX, + &org.ListOrganizationsRequest{ + Queries: []*org.SearchQuery{}, + }, + func(ctx context.Context, request *org.ListOrganizationsRequest) ([]orgAttr, error) { + name := gofakeit.Name() + orgResp := Tester.CreateOrganization(ctx, name, gofakeit.Email()) + deactivateOrgResp := Tester.DeactivateOrganization(ctx, orgResp.GetOrganizationId()) + request.Queries = []*org.SearchQuery{ + OrganizationIdQuery(orgResp.GetOrganizationId()), + OrganizationStateQuery(org.OrganizationState_ORGANIZATION_STATE_INACTIVE), + } + return []orgAttr{{ + ID: orgResp.GetOrganizationId(), + Name: name, + Details: &object.Details{ + ResourceOwner: deactivateOrgResp.GetDetails().GetResourceOwner(), + Sequence: deactivateOrgResp.GetDetails().GetSequence(), + ChangeDate: deactivateOrgResp.GetDetails().GetChangeDate(), + }, + }}, nil + }, + }, + want: &org.ListOrganizationsResponse{ + Details: &object.ListDetails{ + TotalResult: 1, + Timestamp: timestamppb.Now(), + }, + SortingColumn: 0, + Result: []*org.Organization{ + { + State: org.OrganizationState_ORGANIZATION_STATE_INACTIVE, + Details: &object.Details{}, + }, + }, + }, + }, + { + name: "list org by domain, ok, sorted", + args: args{ + CTX, + &org.ListOrganizationsRequest{ + Queries: []*org.SearchQuery{ + OrganizationDomainQuery(Tester.Organisation.Domain), + }, + SortingColumn: org.OrganizationFieldName_ORGANIZATION_FIELD_NAME_NAME, + }, + nil, + }, + want: &org.ListOrganizationsResponse{ + Details: &object.ListDetails{ + TotalResult: 1, + Timestamp: timestamppb.Now(), + }, + SortingColumn: 1, + Result: []*org.Organization{ + { + State: org.OrganizationState_ORGANIZATION_STATE_ACTIVE, + Name: Tester.Organisation.Name, + Details: &object.Details{ + Sequence: Tester.Organisation.Sequence, + ChangeDate: timestamppb.New(Tester.Organisation.ChangeDate), + ResourceOwner: Tester.Organisation.ResourceOwner, + }, + Id: Tester.Organisation.ID, + PrimaryDomain: Tester.Organisation.Domain, + }, + }, + }, + }, + { + name: "list org, no result", + args: args{ + CTX, + &org.ListOrganizationsRequest{ + Queries: []*org.SearchQuery{ + OrganizationDomainQuery("notexisting"), + }, + }, + nil, + }, + want: &org.ListOrganizationsResponse{ + Details: &object.ListDetails{ + TotalResult: 0, + Timestamp: timestamppb.Now(), + }, + SortingColumn: 0, + Result: []*org.Organization{}, + }, + }, + { + name: "list org, no login", + args: args{ + context.Background(), + &org.ListOrganizationsRequest{ + Queries: []*org.SearchQuery{ + OrganizationDomainQuery("nopermission"), + }, + }, + nil, + }, + wantErr: true, + }, + { + name: "list org, no permission", + args: args{ + UserCTX, + &org.ListOrganizationsRequest{}, + nil, + }, + want: &org.ListOrganizationsResponse{ + Details: &object.ListDetails{ + TotalResult: 0, + Timestamp: timestamppb.Now(), + }, + SortingColumn: 1, + Result: []*org.Organization{}, + }, + }, + { + name: "list org, no permission org owner", + args: args{ + OwnerCTX, + &org.ListOrganizationsRequest{ + Queries: []*org.SearchQuery{ + OrganizationDomainQuery("nopermission"), + }, + }, + nil, + }, + want: &org.ListOrganizationsResponse{ + Details: &object.ListDetails{ + TotalResult: 0, + Timestamp: timestamppb.Now(), + }, + SortingColumn: 1, + Result: []*org.Organization{}, + }, + }, + { + name: "list org, org owner", + args: args{ + OwnerCTX, + &org.ListOrganizationsRequest{}, + nil, + }, + want: &org.ListOrganizationsResponse{ + Details: &object.ListDetails{ + TotalResult: 1, + Timestamp: timestamppb.Now(), + }, + SortingColumn: 1, + Result: []*org.Organization{ + { + State: org.OrganizationState_ORGANIZATION_STATE_ACTIVE, + Name: Tester.Organisation.Name, + Details: &object.Details{ + Sequence: Tester.Organisation.Sequence, + ChangeDate: timestamppb.New(Tester.Organisation.ChangeDate), + ResourceOwner: Tester.Organisation.ResourceOwner, + }, + Id: Tester.Organisation.ID, + PrimaryDomain: Tester.Organisation.Domain, + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.args.dep != nil { + orgs, err := tt.args.dep(tt.args.ctx, tt.args.req) + require.NoError(t, err) + if len(orgs) > 0 { + for i, org := range orgs { + tt.want.Result[i].Name = org.Name + tt.want.Result[i].Id = org.ID + tt.want.Result[i].Details = org.Details + } + } + } + + retryDuration := time.Minute + if ctxDeadline, ok := CTX.Deadline(); ok { + retryDuration = time.Until(ctxDeadline) + } + require.EventuallyWithT(t, func(ttt *assert.CollectT) { + got, listErr := Client.ListOrganizations(tt.args.ctx, tt.args.req) + assertErr := assert.NoError + if tt.wantErr { + assertErr = assert.Error + } + assertErr(ttt, listErr) + if listErr != nil { + return + } + + // totalResult is unrelated to the tests here so gets carried over, can vary from the count of results due to permissions + tt.want.Details.TotalResult = got.Details.TotalResult + // always first check length, otherwise its failed anyway + assert.Len(ttt, got.Result, len(tt.want.Result)) + + for i := range tt.want.Result { + // domain from result, as it is generated though the create + tt.want.Result[i].PrimaryDomain = got.Result[i].PrimaryDomain + // sequence from result, as it can be with different sequence from create + tt.want.Result[i].Details.Sequence = got.Result[i].Details.Sequence + } + + for i := range tt.want.Result { + assert.Contains(ttt, got.Result, tt.want.Result[i]) + } + integration.AssertListDetails(t, tt.want, got) + }, retryDuration, time.Millisecond*100, "timeout waiting for expected user result") + }) + } +} + +func OrganizationIdQuery(resourceowner string) *org.SearchQuery { + return &org.SearchQuery{Query: &org.SearchQuery_IdQuery{ + IdQuery: &org.OrganizationIDQuery{ + Id: resourceowner, + }, + }} +} + +func OrganizationNameQuery(name string) *org.SearchQuery { + return &org.SearchQuery{Query: &org.SearchQuery_NameQuery{ + NameQuery: &org.OrganizationNameQuery{ + Name: name, + }, + }} +} + +func OrganizationNamePrefixQuery(name string) *org.SearchQuery { + return &org.SearchQuery{Query: &org.SearchQuery_NameQuery{ + NameQuery: &org.OrganizationNameQuery{ + Name: name, + Method: object.TextQueryMethod_TEXT_QUERY_METHOD_STARTS_WITH, + }, + }} +} + +func OrganizationDomainQuery(domain string) *org.SearchQuery { + return &org.SearchQuery{Query: &org.SearchQuery_DomainQuery{ + DomainQuery: &org.OrganizationDomainQuery{ + Domain: domain, + }, + }} +} + +func OrganizationStateQuery(state org.OrganizationState) *org.SearchQuery { + return &org.SearchQuery{Query: &org.SearchQuery_StateQuery{ + StateQuery: &org.OrganizationStateQuery{ + State: state, + }, + }} +} diff --git a/internal/api/grpc/user/v2/query.go b/internal/api/grpc/user/v2/query.go index 95262a66a..d40e4d47d 100644 --- a/internal/api/grpc/user/v2/query.go +++ b/internal/api/grpc/user/v2/query.go @@ -39,11 +39,10 @@ func (s *Server) ListUsers(ctx context.Context, req *user.ListUsersRequest) (*us if err != nil { return nil, err } - res, err := s.query.SearchUsers(ctx, queries) + res, err := s.query.SearchUsers(ctx, queries, s.checkPermission) if err != nil { return nil, err } - res.RemoveNoPermission(ctx, s.checkPermission) return &user.ListUsersResponse{ Result: UsersToPb(res.Users, s.assetAPIPrefix(ctx)), Details: object.ToListDetails(res.SearchResponse), diff --git a/internal/api/grpc/user/v2/query_integration_test.go b/internal/api/grpc/user/v2/query_integration_test.go index f7fcf8fbe..7509a9d43 100644 --- a/internal/api/grpc/user/v2/query_integration_test.go +++ b/internal/api/grpc/user/v2/query_integration_test.go @@ -13,8 +13,8 @@ import ( "github.com/stretchr/testify/require" "google.golang.org/protobuf/types/known/timestamppb" - object "github.com/zitadel/zitadel/pkg/grpc/object/v2" - user "github.com/zitadel/zitadel/pkg/grpc/user/v2" + "github.com/zitadel/zitadel/pkg/grpc/object/v2" + "github.com/zitadel/zitadel/pkg/grpc/user/v2" "github.com/zitadel/zitadel/internal/integration" ) @@ -914,6 +914,10 @@ func TestServer_ListUsers(t *testing.T) { assert.Len(ttt, tt.want.Result, len(infos)) // always first check length, otherwise its failed anyway assert.Len(ttt, got.Result, len(tt.want.Result)) + + // totalResult is unrelated to the tests here so gets carried over, can vary from the count of results due to permissions + tt.want.Details.TotalResult = got.Details.TotalResult + // fill in userid and username as it is generated for i := range infos { tt.want.Result[i].UserId = infos[i].UserID diff --git a/internal/api/grpc/user/v2beta/query.go b/internal/api/grpc/user/v2beta/query.go index 0eaeba5ca..28e0a0c2e 100644 --- a/internal/api/grpc/user/v2beta/query.go +++ b/internal/api/grpc/user/v2beta/query.go @@ -39,11 +39,10 @@ func (s *Server) ListUsers(ctx context.Context, req *user.ListUsersRequest) (*us if err != nil { return nil, err } - res, err := s.query.SearchUsers(ctx, queries) + res, err := s.query.SearchUsers(ctx, queries, s.checkPermission) if err != nil { return nil, err } - res.RemoveNoPermission(ctx, s.checkPermission) return &user.ListUsersResponse{ Result: UsersToPb(res.Users, s.assetAPIPrefix(ctx)), Details: object.ToListDetails(res.SearchResponse), diff --git a/internal/api/grpc/user/v2beta/query_integration_test.go b/internal/api/grpc/user/v2beta/query_integration_test.go index 124e47bb2..1b375e409 100644 --- a/internal/api/grpc/user/v2beta/query_integration_test.go +++ b/internal/api/grpc/user/v2beta/query_integration_test.go @@ -923,6 +923,10 @@ func TestServer_ListUsers(t *testing.T) { // always first check length, otherwise its failed anyway assert.Len(ttt, got.Result, len(tt.want.Result)) // fill in userid and username as it is generated + + // totalResult is unrelated to the tests here so gets carried over, can vary from the count of results due to permissions + tt.want.Details.TotalResult = got.Details.TotalResult + for i := range infos { tt.want.Result[i].UserId = infos[i].UserID tt.want.Result[i].Username = infos[i].Username diff --git a/internal/api/ui/login/login.go b/internal/api/ui/login/login.go index bda1ecac5..57f6a5f9a 100644 --- a/internal/api/ui/login/login.go +++ b/internal/api/ui/login/login.go @@ -171,7 +171,7 @@ func (l *Login) getClaimedUserIDsOfOrgDomain(ctx context.Context, orgName string if err != nil { return nil, err } - users, err := l.query.SearchUsers(ctx, &query.UserSearchQueries{Queries: []query.SearchQuery{loginName}}) + users, err := l.query.SearchUsers(ctx, &query.UserSearchQueries{Queries: []query.SearchQuery{loginName}}, nil) if err != nil { return nil, err } diff --git a/internal/domain/permission.go b/internal/domain/permission.go index 75d7f792f..7e0cfcfc8 100644 --- a/internal/domain/permission.go +++ b/internal/domain/permission.go @@ -33,6 +33,7 @@ const ( PermissionUserCredentialWrite = "user.credential.write" PermissionSessionWrite = "session.write" PermissionSessionDelete = "session.delete" + PermissionOrgRead = "org.read" PermissionIDPRead = "iam.idp.read" PermissionOrgIDPRead = "org.idp.read" ) diff --git a/internal/integration/client.go b/internal/integration/client.go index a69ce6a1e..55f80938b 100644 --- a/internal/integration/client.go +++ b/internal/integration/client.go @@ -15,6 +15,7 @@ import ( "golang.org/x/oauth2" "golang.org/x/text/language" "google.golang.org/grpc" + "google.golang.org/grpc/metadata" "google.golang.org/protobuf/types/known/durationpb" "google.golang.org/protobuf/types/known/structpb" @@ -258,6 +259,39 @@ func (s *Tester) CreateOrganization(ctx context.Context, name, adminEmail string return resp } +func (s *Tester) DeactivateOrganization(ctx context.Context, orgID string) *mgmt.DeactivateOrgResponse { + resp, err := s.Client.Mgmt.DeactivateOrg( + SetOrgID(ctx, orgID), + &mgmt.DeactivateOrgRequest{}, + ) + logging.OnError(err).Fatal("deactivate org") + return resp +} + +func SetOrgID(ctx context.Context, orgID string) context.Context { + md, ok := metadata.FromOutgoingContext(ctx) + if !ok { + return metadata.AppendToOutgoingContext(ctx, "x-zitadel-orgid", orgID) + } + md.Set("x-zitadel-orgid", orgID) + return metadata.NewOutgoingContext(ctx, md) +} + +func (s *Tester) CreateOrganizationWithUserID(ctx context.Context, name, userID string) *org.AddOrganizationResponse { + resp, err := s.Client.OrgV2.AddOrganization(ctx, &org.AddOrganizationRequest{ + Name: name, + Admins: []*org.AddOrganizationRequest_Admin{ + { + UserType: &org.AddOrganizationRequest_Admin_UserId{ + UserId: userID, + }, + }, + }, + }) + logging.OnError(err).Fatal("create org") + return resp +} + func (s *Tester) CreateHumanUserVerified(ctx context.Context, org, email string) *user.AddHumanUserResponse { resp, err := s.Client.UserV2.AddHumanUser(ctx, &user.AddHumanUserRequest{ Organization: &object.Organization{ diff --git a/internal/query/org.go b/internal/query/org.go index 9bff752cb..50e2d4dbd 100644 --- a/internal/query/org.go +++ b/internal/query/org.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "errors" + "slices" "time" sq "github.com/Masterminds/squirrel" @@ -82,6 +83,17 @@ type Org struct { Domain string } +func orgsCheckPermission(ctx context.Context, orgs *Orgs, permissionCheck domain_pkg.PermissionCheck) { + orgs.Orgs = slices.DeleteFunc(orgs.Orgs, + func(org *Org) bool { + if err := permissionCheck(ctx, domain_pkg.PermissionOrgRead, org.ID, org.ID); err != nil { + return true + } + return false + }, + ) +} + type OrgSearchQueries struct { SearchRequest Queries []SearchQuery @@ -254,7 +266,18 @@ func (q *Queries) ExistsOrg(ctx context.Context, id, domain string) (verifiedID return org.ID, nil } -func (q *Queries) SearchOrgs(ctx context.Context, queries *OrgSearchQueries) (orgs *Orgs, err error) { +func (q *Queries) SearchOrgs(ctx context.Context, queries *OrgSearchQueries, permissionCheck domain_pkg.PermissionCheck) (*Orgs, error) { + orgs, err := q.searchOrgs(ctx, queries) + if err != nil { + return nil, err + } + if permissionCheck != nil { + orgsCheckPermission(ctx, orgs, permissionCheck) + } + return orgs, nil +} + +func (q *Queries) searchOrgs(ctx context.Context, queries *OrgSearchQueries) (orgs *Orgs, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() diff --git a/internal/query/org_test.go b/internal/query/org_test.go index 692315639..b8c6073db 100644 --- a/internal/query/org_test.go +++ b/internal/query/org_test.go @@ -10,6 +10,7 @@ import ( "testing" "github.com/DATA-DOG/go-sqlmock" + "github.com/stretchr/testify/require" "github.com/zitadel/zitadel/internal/database" db_mock "github.com/zitadel/zitadel/internal/database/mock" @@ -441,3 +442,126 @@ func TestQueries_IsOrgUnique(t *testing.T) { } } + +func TestOrg_RemoveNoPermission(t *testing.T) { + type want struct { + orgs []*Org + } + tests := []struct { + name string + want want + orgs *Orgs + permissions []string + }{ + { + "permissions for all", + want{ + orgs: []*Org{ + {ID: "first"}, {ID: "second"}, {ID: "third"}, + }, + }, + &Orgs{ + Orgs: []*Org{ + {ID: "first"}, {ID: "second"}, {ID: "third"}, + }, + }, + []string{"first", "second", "third"}, + }, + { + "permissions for one, first", + want{ + orgs: []*Org{ + {ID: "first"}, + }, + }, + &Orgs{ + Orgs: []*Org{ + {ID: "first"}, {ID: "second"}, {ID: "third"}, + }, + }, + []string{"first"}, + }, + { + "permissions for one, second", + want{ + orgs: []*Org{ + {ID: "second"}, + }, + }, + &Orgs{ + Orgs: []*Org{ + {ID: "first"}, {ID: "second"}, {ID: "third"}, + }, + }, + []string{"second"}, + }, + { + "permissions for one, third", + want{ + orgs: []*Org{ + {ID: "third"}, + }, + }, + &Orgs{ + Orgs: []*Org{ + {ID: "first"}, {ID: "second"}, {ID: "third"}, + }, + }, + []string{"third"}, + }, + { + "permissions for two, first third", + want{ + orgs: []*Org{ + {ID: "first"}, {ID: "third"}, + }, + }, + &Orgs{ + Orgs: []*Org{ + {ID: "first"}, {ID: "second"}, {ID: "third"}, + }, + }, + []string{"first", "third"}, + }, + { + "permissions for two, second third", + want{ + orgs: []*Org{ + {ID: "second"}, {ID: "third"}, + }, + }, + &Orgs{ + Orgs: []*Org{ + {ID: "first"}, {ID: "second"}, {ID: "third"}, + }, + }, + []string{"second", "third"}, + }, + { + "no permissions", + want{ + orgs: []*Org{}, + }, + &Orgs{ + Orgs: []*Org{ + {ID: "first"}, {ID: "second"}, {ID: "third"}, + }, + }, + []string{}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + checkPermission := func(ctx context.Context, permission, orgID, resourceID string) (err error) { + for _, perm := range tt.permissions { + if resourceID == perm { + return nil + } + } + return errors.New("failed") + } + orgsCheckPermission(context.Background(), tt.orgs, checkPermission) + require.Equal(t, tt.want.orgs, tt.orgs.Orgs) + }) + } +} diff --git a/internal/query/user.go b/internal/query/user.go index bb3758b17..497cd89d6 100644 --- a/internal/query/user.go +++ b/internal/query/user.go @@ -5,6 +5,7 @@ import ( "database/sql" _ "embed" "errors" + "slices" "strings" "time" @@ -123,27 +124,18 @@ type NotifyUser struct { PasswordSet bool } -func (u *Users) RemoveNoPermission(ctx context.Context, permissionCheck domain.PermissionCheck) { - removableIndexes := make([]int, 0) - for i := range u.Users { - ctxData := authz.GetCtxData(ctx) - if ctxData.UserID != u.Users[i].ID { - if err := permissionCheck(ctx, domain.PermissionUserRead, u.Users[i].ResourceOwner, u.Users[i].ID); err != nil { - removableIndexes = append(removableIndexes, i) +func usersCheckPermission(ctx context.Context, users *Users, permissionCheck domain.PermissionCheck) { + ctxData := authz.GetCtxData(ctx) + users.Users = slices.DeleteFunc(users.Users, + func(user *User) bool { + if ctxData.UserID != user.ID { + if err := permissionCheck(ctx, domain.PermissionUserRead, user.ResourceOwner, user.ID); err != nil { + return true + } } - } - } - removed := 0 - for _, removeIndex := range removableIndexes { - u.Users = removeUser(u.Users, removeIndex-removed) - removed++ - } - // reset count as some users could be removed - u.SearchResponse.Count = uint64(len(u.Users)) -} - -func removeUser(slice []*User, s int) []*User { - return append(slice[:s], slice[s+1:]...) + return false + }, + ) } type UserSearchQueries struct { @@ -597,7 +589,18 @@ func (q *Queries) GetNotifyUser(ctx context.Context, shouldTriggered bool, queri return user, err } -func (q *Queries) SearchUsers(ctx context.Context, queries *UserSearchQueries) (users *Users, err error) { +func (q *Queries) SearchUsers(ctx context.Context, queries *UserSearchQueries, permissionCheck domain.PermissionCheck) (*Users, error) { + users, err := q.searchUsers(ctx, queries) + if err != nil { + return nil, err + } + if permissionCheck != nil { + usersCheckPermission(ctx, users, permissionCheck) + } + return users, nil +} + +func (q *Queries) searchUsers(ctx context.Context, queries *UserSearchQueries) (users *Users, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() diff --git a/internal/query/user_test.go b/internal/query/user_test.go index 04271fb64..0a6b0c36e 100644 --- a/internal/query/user_test.go +++ b/internal/query/user_test.go @@ -17,7 +17,7 @@ import ( "github.com/zitadel/zitadel/internal/zerrors" ) -func Test_RemoveNoPermission(t *testing.T) { +func TestUser_RemoveNoPermission(t *testing.T) { type want struct { users []*User } @@ -134,7 +134,7 @@ func Test_RemoveNoPermission(t *testing.T) { } return errors.New("failed") } - tt.users.RemoveNoPermission(context.Background(), checkPermission) + usersCheckPermission(context.Background(), tt.users, checkPermission) require.Equal(t, tt.want.users, tt.users.Users) }) } diff --git a/proto/zitadel/org/v2/org.proto b/proto/zitadel/org/v2/org.proto new file mode 100644 index 000000000..1f23b85eb --- /dev/null +++ b/proto/zitadel/org/v2/org.proto @@ -0,0 +1,43 @@ +syntax = "proto3"; + +package zitadel.org.v2; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/org/v2;org"; + +import "google/api/field_behavior.proto"; +import "google/protobuf/timestamp.proto"; +import "protoc-gen-openapiv2/options/annotations.proto"; +import "validate/validate.proto"; +import "zitadel/object/v2/object.proto"; + + +message Organization { + // Unique identifier of the organization. + string id = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"69629023906488334\"" + } + ]; + zitadel.object.v2.Details details = 2; + // Current state of the organization, for example active, inactive and deleted. + OrganizationState state = 3; + // Name of the organization. + string name = 4 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"ZITADEL\""; + } + ]; + // Primary domain used in the organization. + string primary_domain = 5 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"zitadel.cloud\""; + } + ]; +} + +enum OrganizationState { + ORGANIZATION_STATE_UNSPECIFIED = 0; + ORGANIZATION_STATE_ACTIVE = 1; + ORGANIZATION_STATE_INACTIVE = 2; + ORGANIZATION_STATE_REMOVED = 3; +} \ No newline at end of file diff --git a/proto/zitadel/org/v2/org_service.proto b/proto/zitadel/org/v2/org_service.proto index 9b0006c46..3917fc85a 100644 --- a/proto/zitadel/org/v2/org_service.proto +++ b/proto/zitadel/org/v2/org_service.proto @@ -1,6 +1,5 @@ syntax = "proto3"; - package zitadel.org.v2; import "zitadel/object/v2/object.proto"; @@ -18,12 +17,14 @@ import "google/protobuf/duration.proto"; import "google/protobuf/struct.proto"; import "protoc-gen-openapiv2/options/annotations.proto"; import "validate/validate.proto"; +import "zitadel/org/v2/org.proto"; +import "zitadel/org/v2/query.proto"; option go_package = "github.com/zitadel/zitadel/pkg/grpc/org/v2;org"; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = { info: { - title: "User Service"; + title: "Organization Service"; version: "2.0"; description: "This API is intended to manage organizations in a ZITADEL instance."; contact:{ @@ -111,7 +112,9 @@ option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = { service OrganizationService { - // Create a new organization and grant the user(s) permission to manage it + // Create an Organization + // + // Create a new organization with an administrative user. If no specific roles are sent for the users, they will be granted the role ORG_OWNER. rpc AddOrganization(AddOrganizationRequest) returns (AddOrganizationResponse) { option (google.api.http) = { post: "/v2/organizations" @@ -128,8 +131,6 @@ service OrganizationService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - summary: "Create an Organization"; - description: "Create a new organization with an administrative user. If no specific roles are sent for the users, they will be granted the role ORG_OWNER." responses: { key: "200" value: { @@ -138,6 +139,42 @@ service OrganizationService { }; }; } + + // Search Organizations + // + // Search for Organizations. By default, we will return all organization of the instance. Make sure to include a limit and sorting for pagination.. + rpc ListOrganizations(ListOrganizationsRequest) returns (ListOrganizationsResponse) { + option (google.api.http) = { + post: "/v2/organizations/_search"; + body: "*"; + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200" + value: { + description: "A list of all organizations matching the query"; + } + }; + responses: { + key: "400"; + value: { + description: "invalid list query"; + schema: { + json_schema: { + ref: "#/definitions/rpcStatus"; + }; + }; + }; + }; + }; + } } message AddOrganizationRequest{ @@ -172,3 +209,18 @@ message AddOrganizationResponse{ string organization_id = 2; repeated CreatedAdmin created_admins = 3; } + +message ListOrganizationsRequest { + //list limitations and ordering + zitadel.object.v2.ListQuery query = 1; + // the field the result is sorted + zitadel.org.v2.OrganizationFieldName sorting_column = 2; + //criteria the client is looking for + repeated zitadel.org.v2.SearchQuery queries = 3; +} + +message ListOrganizationsResponse { + zitadel.object.v2.ListDetails details = 1; + zitadel.org.v2.OrganizationFieldName sorting_column = 2; + repeated zitadel.org.v2.Organization result = 3; +} diff --git a/proto/zitadel/org/v2/query.proto b/proto/zitadel/org/v2/query.proto new file mode 100644 index 000000000..bf0d8fb92 --- /dev/null +++ b/proto/zitadel/org/v2/query.proto @@ -0,0 +1,83 @@ +syntax = "proto3"; + +package zitadel.org.v2; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/org/v2;org"; + +import "google/api/annotations.proto"; +import "google/api/field_behavior.proto"; +import "protoc-gen-openapiv2/options/annotations.proto"; +import "validate/validate.proto"; +import "zitadel/org/v2/org.proto"; +import "zitadel/object/v2/object.proto"; + + +message SearchQuery { + oneof query { + option (validate.required) = true; + + OrganizationNameQuery name_query = 1; + OrganizationDomainQuery domain_query = 2; + OrganizationStateQuery state_query = 3; + OrganizationIDQuery id_query = 4; + } +} + +message OrganizationNameQuery { + // Name of the organization. + string name = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"gigi-giraffe\""; + } + ]; + // Defines which text equality method is used. + zitadel.object.v2.TextQueryMethod method = 2 [ + (validate.rules).enum.defined_only = true + ]; +} + +message OrganizationDomainQuery { + // Domain used in organization, not necessary primary domain. + string domain = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"citadel.cloud\""; + } + ]; + // Defines which text equality method is used. + zitadel.object.v2.TextQueryMethod method = 2 [ + (validate.rules).enum.defined_only = true + ]; +} + +message OrganizationStateQuery { + // Current state of the organization. + OrganizationState state = 1 [ + (validate.rules).enum.defined_only = true + ]; +} + +message OrganizationIDQuery { + // Unique identifier of the organization. + string id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"69629023906488334\"" + } + ]; +} + +enum OrganizationFieldName { + ORGANIZATION_FIELD_NAME_UNSPECIFIED = 0; + ORGANIZATION_FIELD_NAME_NAME = 1; +} \ No newline at end of file diff --git a/proto/zitadel/user/v2/user_service.proto b/proto/zitadel/user/v2/user_service.proto index 11230594d..214c600bd 100644 --- a/proto/zitadel/user/v2/user_service.proto +++ b/proto/zitadel/user/v2/user_service.proto @@ -179,9 +179,6 @@ service UserService { auth_option: { permission: "authenticated" } - http_response: { - success_code: 200 - } }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {