diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index a168df4fd63ef48a9114591ce2f5c3babc734c8f..ec212ca3d61b2e50e23bd8f2b135668a1e816e92 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -73,7 +73,7 @@ Returns [`CiConfig`](#ciconfig). ### `Query.ciMinutesUsage` -Monthly CI minutes usage data for the current user. +CI/CD minutes usage data for a namespace. Returns [`CiMinutesNamespaceMonthlyUsageConnection`](#ciminutesnamespacemonthlyusageconnection). @@ -81,6 +81,12 @@ 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 | +| ---- | ---- | ----------- | +| <a id="queryciminutesusagenamespaceid"></a>`namespaceId` | [`NamespaceID`](#namespaceid) | Global ID of the Namespace for the monthly CI/CD minutes usage. | + ### `Query.containerRepository` Find a container repository. diff --git a/ee/app/graphql/ee/types/query_type.rb b/ee/app/graphql/ee/types/query_type.rb index 03b521c017ab678db309906976430825447fc9ef..1658bd517e41d8385c2da01452b8c0f4d37904b4 100644 --- a/ee/app/graphql/ee/types/query_type.rb +++ b/ee/app/graphql/ee/types/query_type.rb @@ -4,7 +4,6 @@ module EE module Types module QueryType extend ActiveSupport::Concern - prepended do field :iteration, ::Types::IterationType, null: true, @@ -70,7 +69,12 @@ module QueryType field :ci_minutes_usage, ::Types::Ci::Minutes::NamespaceMonthlyUsageType.connection_type, null: true, - description: 'Monthly CI minutes usage data for the current user.' + description: 'CI/CD minutes usage data for a namespace.' do + argument :namespace_id, + ::Types::GlobalIDType[::Namespace], + required: false, + description: 'Global ID of the Namespace for the monthly CI/CD minutes usage.' + end end def vulnerability(id:) @@ -87,8 +91,20 @@ def iteration(id:) ::GitlabSchema.find_by_gid(id) end - def ci_minutes_usage - ::Ci::Minutes::NamespaceMonthlyUsage.for_namespace(current_user.namespace) + def ci_minutes_usage(namespace_id: nil) + root_namespace = find_root_namespace(namespace_id) + ::Ci::Minutes::NamespaceMonthlyUsage.for_namespace(root_namespace) + end + + private + + def find_root_namespace(namespace_id) + return current_user&.namespace unless namespace_id + + namespace = ::Gitlab::Graphql::Lazy.force(::GitlabSchema.find_by_gid(namespace_id)) + return unless namespace&.root? + + namespace end end end diff --git a/ee/app/graphql/types/ci/minutes/namespace_monthly_usage_type.rb b/ee/app/graphql/types/ci/minutes/namespace_monthly_usage_type.rb index 8047a70f9988575984ed532d5691046ed8a4c800..994a792aef91f4d4add351c8108729be3cf889af 100644 --- a/ee/app/graphql/types/ci/minutes/namespace_monthly_usage_type.rb +++ b/ee/app/graphql/types/ci/minutes/namespace_monthly_usage_type.rb @@ -7,6 +7,7 @@ module Minutes # this type only exposes data related to the current user class NamespaceMonthlyUsageType < BaseObject graphql_name 'CiMinutesNamespaceMonthlyUsage' + authorize :read_usage field :month, ::GraphQL::Types::String, null: true, description: 'Month related to the usage data.' diff --git a/ee/app/policies/ci/minutes/namespace_monthly_usage_policy.rb b/ee/app/policies/ci/minutes/namespace_monthly_usage_policy.rb new file mode 100644 index 0000000000000000000000000000000000000000..8f5667909982fee947ece0e2b88bc5fec2540b20 --- /dev/null +++ b/ee/app/policies/ci/minutes/namespace_monthly_usage_policy.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Ci + module Minutes + class NamespaceMonthlyUsagePolicy < BasePolicy + delegate { @subject.namespace } + + rule { can?(:owner_access) }.enable :read_usage + end + end +end diff --git a/ee/spec/policies/ci/minutes/namespace_monthly_usage_policy_spec.rb b/ee/spec/policies/ci/minutes/namespace_monthly_usage_policy_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..8f58643daf93e0379c488e59952084c5b26a577f --- /dev/null +++ b/ee/spec/policies/ci/minutes/namespace_monthly_usage_policy_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Ci::Minutes::NamespaceMonthlyUsagePolicy do + let(:group) { create(:group, :private, name: 'test') } + let(:current_user) { create(:user) } + + let(:namespace_monthly_usage) do + create(:ci_namespace_monthly_usage, namespace: group) + end + + subject(:policy) do + described_class.new(current_user, namespace_monthly_usage) + end + + context 'with an owner' do + before do + group.add_owner(current_user) + end + + it { is_expected.to be_allowed(:read_usage) } + end + + context 'with a developer' do + before do + group.add_developer(current_user) + end + + it { is_expected.not_to be_allowed(:read_usage) } + end + + context "with a user's namespace" do + let(:namespace_monthly_usage) do + create(:ci_namespace_monthly_usage, namespace: current_user.namespace) + end + + it { is_expected.to be_allowed(:read_usage) } + end + + context 'with a different namespace' do + let(:namespace_monthly_usage) do + create(:ci_namespace_monthly_usage, namespace: create(:user).namespace) + end + + it { is_expected.not_to be_allowed(:read_usage) } + end +end diff --git a/ee/spec/requests/api/graphql/ci/minutes/usage_spec.rb b/ee/spec/requests/api/graphql/ci/minutes/usage_spec.rb index 1d174898a11b19b38cfce4aeb363fbb8e2b26f5a..b0f5df3880106f1d8b20f6d6639e8cd45d04f60a 100644 --- a/ee/spec/requests/api/graphql/ci/minutes/usage_spec.rb +++ b/ee/spec/requests/api/graphql/ci/minutes/usage_spec.rb @@ -6,70 +6,130 @@ include GraphqlHelpers let_it_be(:user) { create(:user) } - let_it_be(:project) { create(:project, name: 'Project 1', namespace: user.namespace) } + let_it_be(:user_project) { create(:project, name: 'Project 1', namespace: user.namespace) } + let_it_be_with_refind(:group) { create(:group, :public, name: 'test') } before(:all) do create(:ci_namespace_monthly_usage, namespace: user.namespace, amount_used: 50, date: Date.new(2021, 5, 1)) - create(:ci_project_monthly_usage, project: project, amount_used: 50, date: Date.new(2021, 5, 1)) + create(:ci_project_monthly_usage, project: user_project, amount_used: 40, date: Date.new(2021, 5, 1)) + + create(:ci_namespace_monthly_usage, namespace: group, amount_used: 100, date: Date.new(2021, 6, 1)) end - it 'returns usage data by month for the current user' do - query = <<-QUERY - { - ciMinutesUsage { - nodes { - minutes - month - projects { - nodes { - name - minutes + subject(:result) { post_graphql(query, current_user: user) } + + context 'when no namespace_id is provided' do + let(:query) do + <<-QUERY + { + ciMinutesUsage { + nodes { + minutes + month + projects { + nodes { + name + minutes + } } } } } - } - QUERY - - post_graphql(query, current_user: user) - - monthly_usage = graphql_data_at(:ci_minutes_usage, :nodes) - expect(monthly_usage).to contain_exactly({ - 'month' => 'May', - 'minutes' => 50, - 'projects' => { 'nodes' => [{ - 'name' => 'Project 1', - 'minutes' => 50 - }] } - }) + QUERY + end + + it 'returns usage data by month for the current user' do + subject + + monthly_usage = graphql_data_at(:ci_minutes_usage, :nodes) + expect(monthly_usage).to contain_exactly({ + 'month' => 'May', + 'minutes' => 50, + 'projects' => { 'nodes' => [{ + 'name' => 'Project 1', + 'minutes' => 40 + }] } + }) + end + + it 'does not create N+1 queries' do + control_count = ActiveRecord::QueryRecorder.new do + post_graphql(query, current_user: user) + end + expect(graphql_errors).to be_nil + + project_2 = create(:project, name: 'Project 2', namespace: user.namespace) + create(:ci_project_monthly_usage, project: project_2, amount_used: 50, date: Date.new(2021, 5, 1)) + + expect do + post_graphql(query, current_user: user) + end.not_to exceed_query_limit(control_count) + expect(graphql_errors).to be_nil + end end - it 'does not create N+1 queries' do - query = <<-QUERY - { - ciMinutesUsage { - nodes { - projects { - nodes { - name - } + context 'when namespace_id is provided' do + let(:namespace) { group } + + let(:query) do + <<-QUERY + { + ciMinutesUsage(namespaceId: "#{namespace.to_global_id}") { + nodes { + minutes + month } } } - } - QUERY + QUERY + end + + context 'when group is root' do + context 'when user is an owner' do + before do + group.add_owner(user) + end + + it 'returns the usage data' do + subject + + monthly_usage = graphql_data_at(:ci_minutes_usage, :nodes) + expect(monthly_usage).to contain_exactly({ + 'month' => 'June', + 'minutes' => 100 + }) + end + end - control_count = ActiveRecord::QueryRecorder.new do - post_graphql(query, current_user: user) + context 'when user is not an owner' do + before do + group.add_developer(user) + end + + it 'does not return usage data' do + subject + + monthly_usage = graphql_data_at(:ci_minutes_usage, :nodes) + expect(monthly_usage).to be_empty + end + end end - expect(graphql_errors).to be_nil - project_2 = create(:project, name: 'Project 2', namespace: user.namespace) - create(:ci_project_monthly_usage, project: project_2, amount_used: 50, date: Date.new(2021, 5, 1)) + context 'when group is a subgroup' do + let(:subgroup) { create(:group, :public, parent: group) } + let(:namespace) { subgroup } + + before do + create(:ci_namespace_monthly_usage, namespace: subgroup) + subgroup.add_owner(user) + end - expect do - post_graphql(query, current_user: user) - end.not_to exceed_query_limit(control_count) - expect(graphql_errors).to be_nil + it 'does not return usage data' do + subject + + monthly_usage = graphql_data_at(:ci_minutes_usage, :nodes) + expect(monthly_usage).to be_empty + end + end end end