diff --git a/app/finders/organizations/groups_finder.rb b/app/finders/organizations/groups_finder.rb new file mode 100644 index 0000000000000000000000000000000000000000..2b59a3106a3a62adf9cb4228fccc60f2fef93b5e --- /dev/null +++ b/app/finders/organizations/groups_finder.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +# Organizations::GroupsFinder +# +# Used to find Groups within an Organization +module Organizations + class GroupsFinder + # @param organization [Organizations::Organization] + # @param current_user [User] + # @param params [{ sort: { field: [String], direction: [String] }, search: [String] }] + def initialize(organization:, current_user:, params: {}) + @organization = organization + @current_user = current_user + @params = params + end + + def execute + return Group.none if organization.nil? || !authorized? + + filter_groups(all_accessible_groups) + .then { |groups| sort(groups) } + .then(&:with_route) + end + + private + + attr_reader :organization, :params, :current_user + + def all_accessible_groups + current_user.authorized_groups.in_organization(organization) + end + + def filter_groups(groups) + by_search(groups) + end + + def by_search(groups) + return groups unless params[:search].present? + + groups.search(params[:search]) + end + + def sort(groups) + return default_sort_order(groups) if params[:sort].blank? + + field = params[:sort][:field] + direction = params[:sort][:direction] + groups.reorder(field => direction) # rubocop: disable CodeReuse/ActiveRecord + end + + def default_sort_order(groups) + groups.sort_by_attribute('name_asc') + end + + def authorized? + Ability.allowed?(current_user, :read_organization, organization) + end + end +end diff --git a/app/graphql/resolvers/organizations/groups_resolver.rb b/app/graphql/resolvers/organizations/groups_resolver.rb new file mode 100644 index 0000000000000000000000000000000000000000..0f50713b9b439aa5eb9ff878ad9aeeb4e34aae80 --- /dev/null +++ b/app/graphql/resolvers/organizations/groups_resolver.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Resolvers + module Organizations + class GroupsResolver < BaseResolver + include Gitlab::Graphql::Authorize::AuthorizeResource + include ResolvesGroups + + type Types::GroupType.connection_type, null: true + + authorize :read_group + + argument :search, + GraphQL::Types::String, + required: false, + description: 'Search query for group name or full path.', + alpha: { milestone: '16.4' } + + argument :sort, + Types::Organizations::GroupSortEnum, + description: 'Criteria to sort organization groups by.', + required: false, + default_value: { field: 'name', direction: :asc }, + alpha: { milestone: '16.4' } + + private + + def resolve_groups(**args) + return Group.none if Feature.disabled?(:resolve_organization_groups, context[:current_user]) + + ::Organizations::GroupsFinder + .new(organization: object, current_user: context[:current_user], params: args) + .execute + end + end + end +end diff --git a/app/graphql/resolvers/organizations/organization_resolver.rb b/app/graphql/resolvers/organizations/organization_resolver.rb new file mode 100644 index 0000000000000000000000000000000000000000..9194d9a32c5dd9de8834ec1e46e528bf5ea7b487 --- /dev/null +++ b/app/graphql/resolvers/organizations/organization_resolver.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Resolvers + module Organizations + class OrganizationResolver < BaseResolver + include Gitlab::Graphql::Authorize::AuthorizeResource + + authorize :read_organization + + type Types::Organizations::OrganizationType, null: true + + argument :id, + Types::GlobalIDType[::Organizations::Organization], + required: true, + description: 'ID of the organization.' + + def resolve(id:) + authorized_find!(id: id) + end + end + end +end diff --git a/app/graphql/types/organizations/group_sort_enum.rb b/app/graphql/types/organizations/group_sort_enum.rb new file mode 100644 index 0000000000000000000000000000000000000000..8fb2f553539a2d1bd222538427317d840c78ff01 --- /dev/null +++ b/app/graphql/types/organizations/group_sort_enum.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Types + module Organizations + class GroupSortEnum < BaseEnum + graphql_name 'OrganizationGroupSort' + description 'Values for sorting organization groups' + + sortable_fields = ['ID', 'Name', 'Path', 'Updated at', 'Created at'] + + sortable_fields.each do |field| + value "#{field.upcase.tr(' ', '_')}_ASC", + value: { field: field.downcase.tr(' ', '_'), direction: :asc }, + description: "#{field} in ascending order.", + alpha: { milestone: '16.4' } + + value "#{field.upcase.tr(' ', '_')}_DESC", + value: { field: field.downcase.tr(' ', '_'), direction: :desc }, + description: "#{field} in descending order.", + alpha: { milestone: '16.4' } + end + end + end +end diff --git a/app/graphql/types/organizations/organization_type.rb b/app/graphql/types/organizations/organization_type.rb new file mode 100644 index 0000000000000000000000000000000000000000..791fddc52667dfb6d4dbbf192411443c51442c16 --- /dev/null +++ b/app/graphql/types/organizations/organization_type.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Types + module Organizations + class OrganizationType < BaseObject + graphql_name 'Organization' + + authorize :read_organization + + field :groups, + Types::GroupType.connection_type, + null: false, + description: 'Groups within this organization that the user has access to.', + alpha: { milestone: '16.4' }, + resolver: ::Resolvers::Organizations::GroupsResolver + field :id, + GraphQL::Types::ID, + null: false, + description: 'ID of the organization.', + alpha: { milestone: '16.4' } + field :name, + GraphQL::Types::String, + null: false, + description: 'Name of the organization.', + alpha: { milestone: '16.4' } + field :path, + GraphQL::Types::String, + null: false, + description: 'Path of the organization.', + alpha: { milestone: '16.4' } + end + end +end diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb index 38b8973034d3b82b4a6c87338bc257c3ff679b52..e3dd02110291d71f79b01c4347109ad7b9846e99 100644 --- a/app/graphql/types/query_type.rb +++ b/app/graphql/types/query_type.rb @@ -96,6 +96,12 @@ class QueryType < ::Types::BaseObject required: true, description: 'Global ID of the note.' end + field :organization, + Types::Organizations::OrganizationType, + null: true, + resolver: Resolvers::Organizations::OrganizationResolver, + description: "Find an organization.", + alpha: { milestone: '16.4' } field :package, description: 'Find a package. This field can only be resolved for one query in any single request. Returns `null` if a package has no `default` status.', resolver: Resolvers::PackageDetailsResolver diff --git a/app/models/namespace.rb b/app/models/namespace.rb index abf4ffaead492416d45c55ce73bef4f25ae4ce66..a81d0f047ba1a28b98589edb66f4e859c5327a52 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -166,6 +166,7 @@ class Namespace < ApplicationRecord scope :include_route, -> { includes(:route) } scope :by_parent, -> (parent) { where(parent_id: parent) } scope :filter_by_path, -> (query) { where('lower(path) = :query', query: query.downcase) } + scope :in_organization, -> (organization) { where(organization: organization) } scope :with_statistics, -> do joins('LEFT JOIN project_statistics ps ON ps.namespace_id = namespaces.id') diff --git a/app/models/organizations/organization.rb b/app/models/organizations/organization.rb index 9f2119949fba21c55dfceb5f00ed21d36894cca6..489fd6e0da7c19871df14f43689ab3bb1fa1518d 100644 --- a/app/models/organizations/organization.rb +++ b/app/models/organizations/organization.rb @@ -38,7 +38,7 @@ def to_param end def user?(user) - users.exists?(user.id) + organization_users.exists?(user: user) end private diff --git a/config/feature_flags/development/resolve_organization_groups.yml b/config/feature_flags/development/resolve_organization_groups.yml new file mode 100644 index 0000000000000000000000000000000000000000..7a70c8568a621163c6aaa5ae90f7f336b70e329c --- /dev/null +++ b/config/feature_flags/development/resolve_organization_groups.yml @@ -0,0 +1,8 @@ +--- +name: resolve_organization_groups +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/128733 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/421673 +milestone: '16.3' +type: development +group: group::tenant scale +default_enabled: true diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index aa679dad425e021535518cfa375fa877102c1ade..ff5cdee4009d421b0d5ca534eccb5c0ebb679c61 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -532,6 +532,22 @@ Returns [`Note`](#note). | ---- | ---- | ----------- | | `id` | [`NoteID!`](#noteid) | Global ID of the note. | +### `Query.organization` + +Find an organization. + +WARNING: +**Introduced** in 16.4. +This feature is an Experiment. It can be changed or removed at any time. + +Returns [`Organization`](#organization). + +#### Arguments + +| Name | Type | Description | +| ---- | ---- | ----------- | +| `id` | [`OrganizationsOrganizationID!`](#organizationsorganizationid) | ID of the organization. | + ### `Query.package` Find a package. This field can only be resolved for one query in any single request. Returns `null` if a package has no `default` status. @@ -20221,6 +20237,39 @@ Active period time range for on-call rotation. | `endTime` | [`String`](#string) | End of the rotation active period. | | `startTime` | [`String`](#string) | Start of the rotation active period. | +### `Organization` + +#### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| `id` **{warning-solid}** | [`ID!`](#id) | **Introduced** in 16.4. This feature is an Experiment. It can be changed or removed at any time. ID of the organization. | +| `name` **{warning-solid}** | [`String!`](#string) | **Introduced** in 16.4. This feature is an Experiment. It can be changed or removed at any time. Name of the organization. | +| `path` **{warning-solid}** | [`String!`](#string) | **Introduced** in 16.4. This feature is an Experiment. It can be changed or removed at any time. Path of the organization. | + +#### Fields with arguments + +##### `Organization.groups` + +Groups within this organization that the user has access to. + +WARNING: +**Introduced** in 16.4. +This feature is an Experiment. It can be changed or removed at any time. + +Returns [`GroupConnection!`](#groupconnection). + +This field returns a [connection](#connections). It accepts the +four standard [pagination arguments](#connection-pagination-arguments): +`before: String`, `after: String`, `first: Int`, `last: Int`. + +###### Arguments + +| Name | Type | Description | +| ---- | ---- | ----------- | +| `search` **{warning-solid}** | [`String`](#string) | **Introduced** in 16.4. This feature is an Experiment. It can be changed or removed at any time. Search query for group name or full path. | +| `sort` **{warning-solid}** | [`OrganizationGroupSort`](#organizationgroupsort) | **Introduced** in 16.4. This feature is an Experiment. It can be changed or removed at any time. Criteria to sort organization groups by. | + ### `OrganizationStateCounts` Represents the total number of organizations for the represented states. @@ -27215,6 +27264,23 @@ Rotation length unit of an on-call rotation. | `HOURS` | Hours. | | `WEEKS` | Weeks. | +### `OrganizationGroupSort` + +Values for sorting organization groups. + +| Value | Description | +| ----- | ----------- | +| `CREATED_AT_ASC` **{warning-solid}** | **Introduced** in 16.4. This feature is an Experiment. It can be changed or removed at any time. Created at in ascending order. | +| `CREATED_AT_DESC` **{warning-solid}** | **Introduced** in 16.4. This feature is an Experiment. It can be changed or removed at any time. Created at in descending order. | +| `ID_ASC` **{warning-solid}** | **Introduced** in 16.4. This feature is an Experiment. It can be changed or removed at any time. ID in ascending order. | +| `ID_DESC` **{warning-solid}** | **Introduced** in 16.4. This feature is an Experiment. It can be changed or removed at any time. ID in descending order. | +| `NAME_ASC` **{warning-solid}** | **Introduced** in 16.4. This feature is an Experiment. It can be changed or removed at any time. Name in ascending order. | +| `NAME_DESC` **{warning-solid}** | **Introduced** in 16.4. This feature is an Experiment. It can be changed or removed at any time. Name in descending order. | +| `PATH_ASC` **{warning-solid}** | **Introduced** in 16.4. This feature is an Experiment. It can be changed or removed at any time. Path in ascending order. | +| `PATH_DESC` **{warning-solid}** | **Introduced** in 16.4. This feature is an Experiment. It can be changed or removed at any time. Path in descending order. | +| `UPDATED_AT_ASC` **{warning-solid}** | **Introduced** in 16.4. This feature is an Experiment. It can be changed or removed at any time. Updated at in ascending order. | +| `UPDATED_AT_DESC` **{warning-solid}** | **Introduced** in 16.4. This feature is an Experiment. It can be changed or removed at any time. Updated at in descending order. | + ### `OrganizationSort` Values for sorting organizations. @@ -28688,6 +28754,12 @@ A `NoteableID` is a global ID. It is encoded as a string. An example `NoteableID` is: `"gid://gitlab/Noteable/1"`. +### `OrganizationsOrganizationID` + +A `OrganizationsOrganizationID` is a global ID. It is encoded as a string. + +An example `OrganizationsOrganizationID` is: `"gid://gitlab/Organizations::Organization/1"`. + ### `PackagesConanFileMetadatumID` A `PackagesConanFileMetadatumID` is a global ID. It is encoded as a string. diff --git a/ee/spec/graphql/types/query_type_spec.rb b/ee/spec/graphql/types/query_type_spec.rb index 0ac790cdc8be6332d754ade11c9b847251289237..623a4d3289d7b03eaad3f7f32e7ac11abd345dee 100644 --- a/ee/spec/graphql/types/query_type_spec.rb +++ b/ee/spec/graphql/types/query_type_spec.rb @@ -19,6 +19,7 @@ :instance_security_dashboard, :iteration, :license_history_entries, + :organization, :subscription_future_entries, :vulnerabilities, :vulnerabilities_count_by_day, diff --git a/spec/finders/organizations/groups_finder_spec.rb b/spec/finders/organizations/groups_finder_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..972d80ab036789fdde05dcd1a8226a708c849572 --- /dev/null +++ b/spec/finders/organizations/groups_finder_spec.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Organizations::GroupsFinder, feature_category: :cell do + include AdminModeHelper + + let(:current_user) { user } + let(:params) { {} } + let(:finder) { described_class.new(organization: organization, current_user: current_user, params: params) } + + let_it_be(:organization_user) { create(:organization_user) } + let_it_be(:organization) { organization_user.organization } + let_it_be(:user) { organization_user.user } + let_it_be(:public_group) { create(:group, name: 'public-group', organization: organization) } + let_it_be(:other_group) { create(:group, name: 'other-group', organization: organization) } + let_it_be(:outside_organization_group) { create(:group) } + let_it_be(:private_group) do + create(:group, :private, name: 'private-group', organization: organization) + end + + let_it_be(:no_access_group_in_org) do + create(:group, :private, name: 'no-access', organization: organization) + end + + before_all do + private_group.add_developer(user) + public_group.add_developer(user) + other_group.add_developer(user) + outside_organization_group.add_developer(user) + end + + subject(:result) { finder.execute.to_a } + + describe '#execute' do + context 'when user is not authorized to read the organization' do + let(:current_user) { create(:user) } + + it { is_expected.to be_empty } + end + + context 'when organization is nil' do + let(:finder) { described_class.new(organization: nil, current_user: current_user, params: params) } + + it { is_expected.to be_empty } + end + + context 'when user is authorized to read the organization' do + it 'return all accessible groups' do + expect(result).to contain_exactly(public_group, private_group, other_group) + end + + context 'when search param is passed' do + let(:params) { { search: 'the' } } + + it 'filters the groups by search' do + expect(result).to contain_exactly(other_group) + end + end + + context 'when sort param is not passed' do + it 'return groups sorted by name in ascending order by default' do + expect(result).to eq([other_group, private_group, public_group]) + end + end + + context 'when sort param is passed' do + using RSpec::Parameterized::TableSyntax + + where(:field, :direction, :sorted_groups) do + 'name' | 'asc' | lazy { [other_group, private_group, public_group] } + 'name' | 'desc' | lazy { [public_group, private_group, other_group] } + 'path' | 'asc' | lazy { [other_group, private_group, public_group] } + 'path' | 'desc' | lazy { [public_group, private_group, other_group] } + end + + with_them do + let(:params) { { sort: { field: field, direction: direction } } } + it 'sorts the groups' do + expect(result).to eq(sorted_groups) + end + end + end + end + end +end diff --git a/spec/graphql/types/organizations/group_sort_enum_spec.rb b/spec/graphql/types/organizations/group_sort_enum_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..57915d95c45e168392004a3a9c03b3359d5566f9 --- /dev/null +++ b/spec/graphql/types/organizations/group_sort_enum_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GitlabSchema.types['OrganizationGroupSort'], feature_category: :cell do + let(:sort_values) do + %w[ + ID_ASC + ID_DESC + NAME_ASC + NAME_DESC + PATH_ASC + PATH_DESC + UPDATED_AT_ASC + UPDATED_AT_DESC + CREATED_AT_ASC + CREATED_AT_DESC + ] + end + + specify { expect(described_class.graphql_name).to eq('OrganizationGroupSort') } + + it 'exposes all the organization groups sort values' do + expect(described_class.values.keys).to include(*sort_values) + end +end diff --git a/spec/graphql/types/organizations/organization_type_spec.rb b/spec/graphql/types/organizations/organization_type_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..78c5dea73088e5d0801ddfd3c8085eeaa5ab3cb9 --- /dev/null +++ b/spec/graphql/types/organizations/organization_type_spec.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GitlabSchema.types['Organization'], feature_category: :cell do + let(:expected_fields) { %w[groups id name path] } + + specify { expect(described_class.graphql_name).to eq('Organization') } + specify { expect(described_class).to require_graphql_authorizations(:read_organization) } + specify { expect(described_class).to have_graphql_fields(*expected_fields) } +end diff --git a/spec/graphql/types/query_type_spec.rb b/spec/graphql/types/query_type_spec.rb index 100ecc94f355aef544588139666234407d51c4d6..8bda738751d66d22ab914a9e8f1a5b6aa485d8b0 100644 --- a/spec/graphql/types/query_type_spec.rb +++ b/spec/graphql/types/query_type_spec.rb @@ -23,6 +23,16 @@ end end + describe 'organization field' do + subject { described_class.fields['organization'] } + + it 'finds organization by path' do + is_expected.to have_graphql_arguments(:id) + is_expected.to have_graphql_type(Types::Organizations::OrganizationType) + is_expected.to have_graphql_resolver(Resolvers::Organizations::OrganizationResolver) + end + end + describe 'project field' do subject { described_class.fields['project'] } diff --git a/spec/requests/api/graphql/organizations/organization_query_spec.rb b/spec/requests/api/graphql/organizations/organization_query_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..2d09a53527934d7cfa9edfb3ee3cdaa5681d79ad --- /dev/null +++ b/spec/requests/api/graphql/organizations/organization_query_spec.rb @@ -0,0 +1,141 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'getting organization information', feature_category: :cell do + include GraphqlHelpers + + let(:query) { graphql_query_for(:organization, { id: organization.to_global_id }, organization_fields) } + let(:current_user) { user } + let(:groups) { graphql_data_at(:organization, :groups, :edges, :node) } + let(:organization_fields) do + <<~FIELDS + id + path + groups { + edges { + node { + id + } + } + } + FIELDS + end + + let_it_be(:organization_user) { create(:organization_user) } + let_it_be(:organization) { organization_user.organization } + let_it_be(:user) { organization_user.user } + let_it_be(:public_group) { create(:group, name: 'public-group', organization: organization) } + let_it_be(:other_group) { create(:group, name: 'other-group', organization: organization) } + let_it_be(:outside_organization_group) { create(:group) } + + let_it_be(:private_group) do + create(:group, :private, name: 'private-group', organization: organization) + end + + let_it_be(:no_access_group_in_org) do + create(:group, :private, name: 'no-access', organization: organization) + end + + before_all do + private_group.add_developer(user) + public_group.add_developer(user) + other_group.add_developer(user) + outside_organization_group.add_developer(user) + end + + subject(:request_organization) { post_graphql(query, current_user: current_user) } + + context 'when the user does not have access to the organization' do + let(:current_user) { create(:user) } + + it 'returns the organization as all organizations are public' do + request_organization + + expect(graphql_data['organization']['id']).to eq(organization.to_global_id.to_s) + end + end + + context 'when user has access to the organization' do + it_behaves_like 'a working graphql query' do + before do + request_organization + end + end + + context 'when resolve_organization_groups feature flag is disabled' do + before do + stub_feature_flags(resolve_organization_groups: false) + end + + it 'returns no groups' do + request_organization + + expect(graphql_data['organization']).not_to be_nil + expect(graphql_data['organization']['groups']['edges']).to be_empty + end + end + + context 'with `search` argument' do + let(:search) { 'oth' } + let(:organization_fields) do + <<~FIELDS + id + path + groups(search: "#{search}") { + edges { + node { + id + name + } + } + } + FIELDS + end + + it 'filters groups by name' do + request_organization + + expect(groups).to contain_exactly(a_graphql_entity_for(other_group)) + end + end + + context 'with `sort` argument' do + using RSpec::Parameterized::TableSyntax + + let(:authorized_groups) { [public_group, private_group, other_group] } + + where(:field, :direction, :sorted_groups) do + 'id' | 'asc' | lazy { authorized_groups.sort_by(&:id) } + 'id' | 'desc' | lazy { authorized_groups.sort_by(&:id).reverse } + 'name' | 'asc' | lazy { authorized_groups.sort_by(&:name) } + 'name' | 'desc' | lazy { authorized_groups.sort_by(&:name).reverse } + 'path' | 'asc' | lazy { authorized_groups.sort_by(&:path) } + 'path' | 'desc' | lazy { authorized_groups.sort_by(&:path).reverse } + end + + with_them do + let(:sort) { "#{field}_#{direction}".upcase } + let(:organization_fields) do + <<~FIELDS + id + path + groups(sort: #{sort}) { + edges { + node { + id + } + } + } + FIELDS + end + + it 'sorts the groups' do + request_organization + + expect(groups.pluck('id')).to eq(sorted_groups.map(&:to_global_id).map(&:to_s)) + end + end + end + end +end