diff --git a/app/models/ee/user.rb b/app/models/ee/user.rb index cd578d2905d754aa5d28142b30801f159df93e30..cf96291a0d28b7190c898fedd2eb9a82019f6cfd 100644 --- a/app/models/ee/user.rb +++ b/app/models/ee/user.rb @@ -15,6 +15,9 @@ module User # column directly. validate :auditor_requires_license_add_on, if: :auditor validate :cannot_be_admin_and_auditor + + delegate :shared_runners_minutes_limit, :shared_runners_minutes_limit=, + to: :namespace end module ClassMethods diff --git a/app/models/user.rb b/app/models/user.rb index 331242488d978a5fa039a01bd114022e9176a281..6df0fce772e7bf95d8177cb42054fc2e44e12175 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -65,7 +65,7 @@ def update_tracked_fields!(request) # # Namespace for personal projects - has_one :namespace, -> { where type: nil }, dependent: :destroy, foreign_key: :owner_id + has_one :namespace, -> { where type: nil }, dependent: :destroy, foreign_key: :owner_id, autosave: true # Profile has_many :keys, -> do diff --git a/changelogs/unreleased-ee/add-api-shared_runners_minutes_limit.yml b/changelogs/unreleased-ee/add-api-shared_runners_minutes_limit.yml new file mode 100644 index 0000000000000000000000000000000000000000..0569c1045ef3f610bc1462bd10818820f0b017c5 --- /dev/null +++ b/changelogs/unreleased-ee/add-api-shared_runners_minutes_limit.yml @@ -0,0 +1,4 @@ +--- +title: Add shared_runners_minutes_limit to groups and users API +merge_request: 1942 +author: diff --git a/doc/api/groups.md b/doc/api/groups.md index 7b772a99bb50e39dfb481bc41601ebd4d83cc69b..b5bc627344f1d80edcd9f42c52c0e302eb781fb7 100644 --- a/doc/api/groups.md +++ b/doc/api/groups.md @@ -140,6 +140,7 @@ Example response: "full_name": "Twitter", "full_path": "twitter", "parent_id": null, + "shared_runners_minutes_limit": 133, "projects": [ { "id": 7, @@ -290,6 +291,7 @@ Parameters: - `lfs_enabled` (optional) - Enable/disable Large File Storage (LFS) for the projects in this group - `request_access_enabled` (optional) - Allow users to request member access. - `parent_id` (optional) - The parent group id for creating nested group. +- `shared_runners_minutes_limit` (optional) - (admin-only) Pipeline minutes quota for this group ## Transfer project to group @@ -323,6 +325,7 @@ PUT /groups/:id | `visibility` | string | no | The visibility level of the group. Can be `private`, `internal`, or `public`. | | `lfs_enabled` (optional) | boolean | no | Enable/disable Large File Storage (LFS) for the projects in this group | | `request_access_enabled` | boolean | no | Allow users to request member access. | +| `shared_runners_minutes_limit` | integer | no | (admin-only) Pipeline minutes quota for this group | ```bash curl --request PUT --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v4/groups/5?name=Experimental" diff --git a/doc/api/users.md b/doc/api/users.md index 8b57f8c0aaeaab22ddfa46a1ac5e288c2672904f..76ce729c399d4377546c3103b33ffb96b2e6617b 100644 --- a/doc/api/users.md +++ b/doc/api/users.md @@ -220,7 +220,8 @@ Parameters: "can_create_group": true, "can_create_project": true, "two_factor_enabled": true, - "external": false + "external": false, + "shared_runners_minutes_limit": 133 } ``` @@ -253,6 +254,7 @@ Parameters: - `can_create_group` (optional) - User can create groups - true or false - `confirm` (optional) - Require confirmation - true (default) or false - `external` (optional) - Flags the user as external - true or false(default) +- `shared_runners_minutes_limit` (optional) - Pipeline minutes quota for this user ## User modification @@ -281,6 +283,7 @@ Parameters: - `admin` (optional) - User is admin - true or false (default) - `can_create_group` (optional) - User can create groups - true or false - `external` (optional) - Flags the user as external - true or false(default) +- `shared_runners_minutes_limit` (optional) - Pipeline minutes quota for this user On password update, user will be forced to change it upon next login. Note, at the moment this method does only return a `404` error, diff --git a/lib/api/entities.rb b/lib/api/entities.rb index c4e767bcd29134f808670d3dcd1d5dbbff7c7ece..e4e6862d8fe7b25ac386f43297570720c476d274 100644 --- a/lib/api/entities.rb +++ b/lib/api/entities.rb @@ -41,6 +41,9 @@ class UserPublic < User expose :can_create_project?, as: :can_create_project expose :two_factor_enabled?, as: :two_factor_enabled expose :external + + # EE-only + expose :shared_runners_minutes_limit end class UserWithPrivateDetails < UserPublic @@ -188,6 +191,9 @@ class Group < Grape::Entity class GroupDetail < Group expose :projects, using: Entities::Project expose :shared_projects, using: Entities::Project + + # EE-only + expose :shared_runners_minutes_limit end class RepoCommit < Grape::Entity diff --git a/lib/api/groups.rb b/lib/api/groups.rb index d1d80cabf2b0f738966c80296aa5689448ad4d2a..4b3abc7a752c8c6465256eadf96aca49db73b37e 100644 --- a/lib/api/groups.rb +++ b/lib/api/groups.rb @@ -17,6 +17,7 @@ class Groups < Grape::API optional :membership_lock, type: Boolean, desc: 'Prevent adding new members to project membership within this group' optional :ldap_cn, type: String, desc: 'LDAP Common Name' optional :ldap_access, type: Integer, desc: 'A valid access level' + optional :shared_runners_minutes_limit, type: Integer, desc: '(admin-only) Pipeline minutes quota for this group' all_or_none_of :ldap_cn, :ldap_access end @@ -89,6 +90,9 @@ def present_groups(groups, options = {}) group_access: params.delete(:ldap_access) } + # EE + authenticated_as_admin! if params[:shared_runners_minutes_limit] + group = ::Groups::CreateService.new(current_user, declared_params(include_missing: false)).execute if group.persisted? @@ -100,7 +104,7 @@ def present_groups(groups, options = {}) ) end - present group, with: Entities::Group, current_user: current_user + present group, with: Entities::GroupDetail, current_user: current_user else render_api_error!("Failed to save group #{group.errors.messages}", 400) end @@ -118,13 +122,18 @@ def present_groups(groups, options = {}) optional :name, type: String, desc: 'The name of the group' optional :path, type: String, desc: 'The path of the group' use :optional_params - at_least_one_of :name, :path, :description, :visibility, - :lfs_enabled, :request_access_enabled end put ':id' do group = find_group!(params[:id]) authorize! :admin_group, group + # EE + if params[:shared_runners_minutes_limit].present? && + group.shared_runners_minutes_limit.to_i != + params[:shared_runners_minutes_limit].to_i + authenticated_as_admin! + end + if ::Groups::UpdateService.new(group, current_user, declared_params(include_missing: false)).execute present group, with: Entities::GroupDetail, current_user: current_user else diff --git a/lib/api/users.rb b/lib/api/users.rb index 9d436fc1d91d9fb74fab7b1b66aa6feb11a44f2d..387fb4c50095b1e77a9f5f4e120c44d628220136 100644 --- a/lib/api/users.rb +++ b/lib/api/users.rb @@ -30,6 +30,9 @@ def find_user(params) optional :skip_confirmation, type: Boolean, default: false, desc: 'Flag indicating the account is confirmed' optional :external, type: Boolean, desc: 'Flag indicating the user is an external user' all_or_none_of :extern_uid, :provider + + # EE + optional :shared_runners_minutes_limit, type: Integer, desc: 'Pipeline minutes quota for this user' end end @@ -127,10 +130,6 @@ def find_user(params) optional :name, type: String, desc: 'The name of the user' optional :username, type: String, desc: 'The username of the user' use :optional_attributes - at_least_one_of :email, :password, :name, :username, :skype, :linkedin, - :twitter, :website_url, :organization, :projects_limit, - :extern_uid, :provider, :bio, :location, :admin, - :can_create_group, :confirm, :external end put ":id" do authenticated_as_admin! diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 61c239f3051059dc25de50a0b06e9ec64a8eb14c..53343606b6a82589dbfbf75559de519d807ec89b 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -13,6 +13,14 @@ it { is_expected.to include_module(TokenAuthenticatable) } end + describe 'delegations' do + it { is_expected.to delegate_method(:path).to(:namespace).with_prefix } + + # EE + it { is_expected.to delegate_method(:shared_runners_minutes_limit).to(:namespace) } + it { is_expected.to delegate_method(:shared_runners_minutes_limit=).to(:namespace).with_arguments(133) } + end + describe 'associations' do it { is_expected.to have_one(:namespace) } it { is_expected.to have_many(:snippets).dependent(:destroy) } diff --git a/spec/requests/api/groups_spec.rb b/spec/requests/api/groups_spec.rb index 33fbf8a413a1a76bfffa8279032285f3c9fede49..5204844c55070dd8f2aa085ec505dcb41692cc61 100644 --- a/spec/requests/api/groups_spec.rb +++ b/spec/requests/api/groups_spec.rb @@ -272,6 +272,25 @@ expect(response).to have_http_status(404) end + + # EE + it 'returns 403 for updating shared_runners_minutes_limit' do + expect do + put api("/groups/#{group1.id}", user1), shared_runners_minutes_limit: 133 + end.not_to change { group1.shared_runners_minutes_limit } + + expect(response).to have_http_status(403) + end + + it 'returns 200 if shared_runners_minutes_limit is not changing' do + group1.update(shared_runners_minutes_limit: 133) + + expect do + put api("/groups/#{group1.id}", user1), shared_runners_minutes_limit: 133 + end.not_to change { group1.shared_runners_minutes_limit } + + expect(response).to have_http_status(200) + end end context 'when authenticated as the admin' do @@ -281,6 +300,17 @@ expect(response).to have_http_status(200) expect(json_response['name']).to eq(new_group_name) end + + # EE + it 'updates the group for shared_runners_minutes_limit' do + expect do + put api("/groups/#{group1.id}", admin), shared_runners_minutes_limit: 133 + end.to change { group1.reload.shared_runners_minutes_limit } + .from(nil).to(133) + + expect(response).to have_http_status(200) + expect(json_response['shared_runners_minutes_limit']).to eq(133) + end end context 'when authenticated as an user that can see the group' do @@ -479,23 +509,36 @@ group_attributes = attributes_for(:group, ldap_cn: 'ldap-group', ldap_access: Gitlab::Access::DEVELOPER) expect { post api("/groups", admin), group_attributes }.to change{ LdapGroupLink.count }.by(1) end - end - end - describe "PUT /groups" do - context "when authenticated as user without group permissions" do - it "does not create group" do - put api("/groups/#{group2.id}", user1), attributes_for(:group) - expect(response.status).to eq(404) - end - end + # EE + context 'when shared_runners_minutes_limit is given' do + context 'when the current user is not an admin' do + it "does not create a group with shared_runners_minutes_limit" do + group = attributes_for(:group, { shared_runners_minutes_limit: 133 }) - context "when authenticated as user with group permissions" do - it "updates group" do - group2.update(owner: user2) - put api("/groups/#{group2.id}", user2), { name: 'Renamed' } - expect(response.status).to eq(200) - expect(group2.reload.name).to eq('Renamed') + expect do + post api("/groups", user3), group + end.not_to change { Group.count } + + expect(response).to have_http_status(403) + end + end + + context 'when the current user is an admin' do + it "creates a group with shared_runners_minutes_limit" do + group = attributes_for(:group, { shared_runners_minutes_limit: 133 }) + + expect do + post api("/groups", admin), group + end.to change { Group.count }.by(1) + + created_group = Group.find(json_response['id']) + + expect(created_group.shared_runners_minutes_limit).to eq(133) + expect(response).to have_http_status(201) + expect(json_response['shared_runners_minutes_limit']).to eq(133) + end + end end end end diff --git a/spec/requests/api/users_spec.rb b/spec/requests/api/users_spec.rb index de5995c33128657dfe54ff86592406b1da8fa17f..f4699da829bb701d94dfadcf84c6a8ebeb86fe9c 100644 --- a/spec/requests/api/users_spec.rb +++ b/spec/requests/api/users_spec.rb @@ -425,6 +425,17 @@ expect(user.reload.external?).to be_truthy end + # EE + it "updates shared_runners_minutes_limit" do + expect do + put api("/users/#{user.id}", admin), { shared_runners_minutes_limit: 133 } + end.to change { user.reload.shared_runners_minutes_limit } + .from(nil).to(133) + + expect(response).to have_http_status(200) + expect(json_response['shared_runners_minutes_limit']).to eq(133) + end + it "does not update admin status" do put api("/users/#{admin_user.id}", admin), { can_create_group: false } expect(response).to have_http_status(200) @@ -438,9 +449,22 @@ expect(user.reload.email).not_to eq('invalid email') end - it "is not available for non admin users" do - put api("/users/#{user.id}", user), attributes_for(:user) - expect(response).to have_http_status(403) + context 'when the current user is not an admin' do + it "is not available" do + expect do + put api("/users/#{user.id}", user), attributes_for(:user) + end.not_to change { user.reload.attributes } + + expect(response).to have_http_status(403) + end + + it "cannot update their own shared_runners_minutes_limit" do + expect do + put api("/users/#{user.id}", user), { shared_runners_minutes_limit: 133 } + end.not_to change { user.reload.shared_runners_minutes_limit } + + expect(response).to have_http_status(403) + end end it "returns 404 for non-existing user" do