Update from merge request

This commit is contained in:
root
2025-07-18 13:16:16 +00:00
parent ce2b1f9fcc
commit 42a7734abc
16 changed files with 285 additions and 88 deletions

View File

@ -1 +1 @@
2b6810dc5688ebdbf4024b4885fdc59686a3f1fa
47335750c521ec469a5074962fb28b48b9dc06a6

View File

@ -180,6 +180,8 @@ module Projects
end
def define_runners_variables
return if Feature.enabled?(:vue_project_runners_settings, @project)
@project_runners = @project.runners.ordered.page(params[:project_page]).per(NUMBER_OF_RUNNERS_PER_PAGE).with_tags
@assignable_runners = current_user

View File

@ -6,9 +6,7 @@ module Mutations
class UnassignFromProject < BaseMutation
graphql_name 'RunnerUnassignFromProject'
include FindsProject
authorize :admin_runners
authorize :unassign_runner
argument :runner_id, ::Types::GlobalIDType[::Ci::Runner],
required: true,
@ -19,18 +17,23 @@ module Mutations
description: 'Full path of the project from which the runner will be unassigned.'
def resolve(**args)
project = authorized_find!(args[:project_path])
runner_id = GitlabSchema.parse_gid(args[:runner_id], expected_type: ::Ci::Runner).model_id
runner_project = project.runner_projects.find_by_runner_id(runner_id)
unless runner_project&.runner
raise_resource_not_available_error! "Runner does not exist or is not assigned to this project"
end
runner_project = find_object(**args.slice(:runner_id, :project_path))
raise_resource_not_available_error! unless runner_project
result = ::Ci::Runners::UnassignRunnerService.new(runner_project, current_user).execute
{ errors: result.errors }
end
private
def find_object(runner_id:, project_path:)
project = Project.find_by_full_path(project_path)
runner_id = GitlabSchema.parse_gid(runner_id, expected_type: ::Ci::Runner).model_id
return unless project
project.runner_projects.find_by_runner_id(runner_id)
end
end
end
end

View File

@ -0,0 +1,23 @@
# frozen_string_literal: true
module Ci
class RunnerProjectPolicy < BasePolicy
condition(:locked, scope: :subject) { @subject.runner.locked? }
condition(:assigned_to_owner_project, scope: :subject) { @subject.project == @subject.runner.owner }
condition(:can_admin_project_runners, score: 2) do
Ability.allowed?(@user, :admin_runners, @subject.project)
end
rule { anonymous }.prevent_all
rule { can_admin_project_runners }.enable :unassign_runner
rule { ~admin & locked }.prevent :unassign_runner
rule { assigned_to_owner_project }.prevent :unassign_runner
end
end
Ci::RunnerProjectPolicy.prepend_mod

View File

@ -28,11 +28,9 @@ module Ci
attr_reader :runner, :project, :user
def authorize
unless user.present? && user.can?(:assign_runner, runner)
return ServiceResponse.error(message: 'User not allowed to unassign runner')
end
return ServiceResponse.success if Ability.allowed?(user, :unassign_runner, @runner_project)
unless user.can?(:admin_runners, project)
unless Ability.allowed?(user, :admin_runners, project)
return ServiceResponse.error(message: "User not allowed to manage project's runners")
end
@ -42,7 +40,9 @@ module Ci
)
end
ServiceResponse.success
return ServiceResponse.error(message: 'Runner is locked') if runner.locked && !user&.can_admin_all_resources?
ServiceResponse.error(message: 'User not allowed to unassign runner')
end
end
end

View File

@ -1,8 +1,9 @@
---
migration_job_name: BackfillSnippetRepositoriesSnippetOrganizationId
description: Backfills sharding key `snippet_repositories.snippet_organization_id` from `snippets`.
description: Backfills sharding key `snippet_repositories.snippet_organization_id`
from `snippets`.
feature_category: source_code_management
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/175410
milestone: '17.10'
queued_migration_version: 20241211134715
finalized_by: # version of the migration that finalized this BBM
finalized_by: '20250717232204'

View File

@ -0,0 +1,21 @@
# frozen_string_literal: true
class FinalizeHkBackfillSnippetRepositoriesSnippetOrganizationId < Gitlab::Database::Migration[2.3]
milestone '18.3'
disable_ddl_transaction!
restrict_gitlab_migration gitlab_schema: :gitlab_main_cell
def up
ensure_batched_background_migration_is_finished(
job_class_name: 'BackfillSnippetRepositoriesSnippetOrganizationId',
table_name: :snippet_repositories,
column_name: :snippet_id,
job_arguments: [:snippet_organization_id, :snippets, :organization_id, :snippet_id],
finalize: true
)
end
def down; end
end

View File

@ -0,0 +1 @@
243d4617b21078c793fae163d28ab38a74cded2dde6a1c72cf83ecc0a2150600

View File

@ -2,6 +2,7 @@ accessor
accessors
ACLs
Adafruit
Agentic
Airbnb
Airtable
Akismet

View File

@ -235,6 +235,7 @@ successfully, you must replicate their data using some other means.
| [Dependency Proxy Images](../../../user/packages/dependency_proxy/_index.md) | [**Yes** (15.7)](https://gitlab.com/groups/gitlab-org/-/epics/8833) | [**Yes** (15.7)](https://gitlab.com/groups/gitlab-org/-/epics/8833) | [**Yes** (15.7)](https://gitlab.com/groups/gitlab-org/-/epics/8833) | [**Yes** (16.4)<sup>3</sup>](https://gitlab.com/groups/gitlab-org/-/epics/8056) | |
| [Vulnerability Export](../../../user/application_security/vulnerability_report/_index.md#exporting) | [Not planned](https://gitlab.com/groups/gitlab-org/-/epics/3111) | No | No | No | Not planned because they are ephemeral and sensitive information. They can be regenerated on demand. |
| Packages NPM metadata cache | [Not planned](https://gitlab.com/gitlab-org/gitlab/-/issues/408278) | No | No | No | Not planned because it would not notably improve disaster recovery capabilities nor response times at secondary sites. |
| SBOM Vulnerability Scan Data | [Not planned](https://gitlab.com/gitlab-org/gitlab/-/issues/398199) | No | No | No | Not planned because data is temporary and has a short lifespan with limited impact on disaster recovery capabilities at secondary sites. |
**Footnotes**:

View File

@ -125,3 +125,25 @@ These tests are performed:
For GitLab instances earlier than version 17.10, if you are encountering any issues with the health check for:
- GitLab-hosted Duo, see the [troubleshooting page](troubleshooting.md).
## GitLab Duo Agent Platform service account
GitLab Duo Agent Platform optionally uses a service account as it performs actions on behalf of a user.
The token that authenticates requests is a composite of two identities:
- The primary author, which is the Duo Agent Platform [service account](../profile/service_accounts.md).
This service account is instance-wide and has the Developer role
on the project where the Duo Agent Platform was used. The service account is the owner of the token.
- The secondary author, which is the human user who submitted the quick action.
This user's `id` is included in the scopes of the token.
This composite identity ensures that any activities authored by Duo Agent Platform are
correctly attributed to the Duo Agent Platform service account.
At the same time, the composite identity ensures that there is no
[privilege escalation](https://en.wikipedia.org/wiki/Privilege_escalation) for the human user.
This [dynamic scope](https://github.com/doorkeeper-gem/doorkeeper/pull/1739)
is checked during the authorization of the API request.
When authorization is requested, GitLab validates that both the service account
and the user who originated the quick action have sufficient permissions.

View File

@ -90,6 +90,15 @@ module API
forbidden!("No access granted") unless can?(current_user, :assign_runner, runner)
end
def authenticate_disable_runner!(runner_project)
not_found!('Runner') unless runner_project
return if current_user.can_admin_all_resources?
forbidden!("Runner is locked") if runner_project.runner.locked?
forbidden!("No access granted") unless can?(current_user, :unassign_runner, runner_project)
end
def authenticate_list_runners_jobs!(runner)
return if current_user.can_read_all_resources?
@ -357,10 +366,8 @@ module API
requires :runner_id, type: Integer, desc: 'The ID of a runner'
end
delete ':id/runners/:runner_id' do
authorize! :admin_runners, user_project
runner_project = user_project.runner_projects.find_by_runner_id(params[:runner_id])
not_found!('Runner') unless runner_project
authenticate_disable_runner!(runner_project)
destroy_conditionally!(runner_project) do
response = ::Ci::Runners::UnassignRunnerService.new(runner_project, current_user).execute

View File

@ -39,77 +39,83 @@ RSpec.describe Projects::Settings::CiCdController, feature_category: :continuous
end
end
context 'with assignable project runners' do
let(:project_runner) { create(:ci_runner, :project, projects: [other_project]) }
context 'when vue_project_runners_settings is disabled' do
before do
group.add_maintainer(user)
stub_feature_flags(vue_project_runners_settings: false)
end
it 'sets assignable project runners' do
request
context 'with assignable project runners' do
let(:project_runner) { create(:ci_runner, :project, projects: [other_project]) }
expect(assigns(:assignable_runners)).to contain_exactly(project_runner)
end
end
before do
group.add_maintainer(user)
end
context 'with project runners' do
let(:project_runner) { create(:ci_runner, :project, projects: [project]) }
it 'sets assignable project runners' do
request
it 'sets project runners' do
request
expect(assigns(:project_runners)).to contain_exactly(project_runner)
end
end
context 'with group runners' do
let(:project) { other_project }
let!(:group_runner) { create(:ci_runner, :group, groups: [group]) }
it 'sets group runners' do
request
expect(assigns(:group_runners_count)).to be(1)
expect(assigns(:group_runners)).to contain_exactly(group_runner)
end
end
context 'with instance runners' do
let_it_be(:shared_runner) { create(:ci_runner, :instance) }
it 'sets shared runners' do
request
expect(assigns(:shared_runners_count)).to be(1)
expect(assigns(:shared_runners)).to contain_exactly(shared_runner)
end
end
context 'prevents N+1 queries for tags' do
render_views
def show
get :show, params: { namespace_id: project.namespace, project_id: project }
expect(assigns(:assignable_runners)).to contain_exactly(project_runner)
end
end
it 'has the same number of queries with one tag or with many tags', :request_store do
group.add_maintainer(user)
context 'with project runners' do
let(:project_runner) { create(:ci_runner, :project, projects: [project]) }
show # warmup
it 'sets project runners' do
request
# with one tag
create(:ci_runner, :instance, tag_list: %w[shared_runner])
create(:ci_runner, :project, projects: [other_project], tag_list: %w[project_runner])
create(:ci_runner, :group, groups: [group], tag_list: %w[group_runner])
control = ActiveRecord::QueryRecorder.new { show }
expect(assigns(:project_runners)).to contain_exactly(project_runner)
end
end
# with several tags
create(:ci_runner, :instance, tag_list: %w[shared_runner tag2 tag3])
create(:ci_runner, :project, projects: [other_project], tag_list: %w[project_runner tag2 tag3])
create(:ci_runner, :group, groups: [group], tag_list: %w[group_runner tag2 tag3])
context 'with group runners' do
let(:project) { other_project }
let!(:group_runner) { create(:ci_runner, :group, groups: [group]) }
expect { show }.not_to exceed_query_limit(control)
it 'sets group runners' do
request
expect(assigns(:group_runners_count)).to be(1)
expect(assigns(:group_runners)).to contain_exactly(group_runner)
end
end
context 'with instance runners' do
let_it_be(:shared_runner) { create(:ci_runner, :instance) }
it 'sets shared runners' do
request
expect(assigns(:shared_runners_count)).to be(1)
expect(assigns(:shared_runners)).to contain_exactly(shared_runner)
end
end
context 'prevents N+1 queries for tags' do
render_views
def show
get :show, params: { namespace_id: project.namespace, project_id: project }
end
it 'has the same number of queries with one tag or with many tags', :request_store do
group.add_maintainer(user)
show # warmup
# with one tag
create(:ci_runner, :instance, tag_list: %w[shared_runner])
create(:ci_runner, :project, projects: [other_project], tag_list: %w[project_runner])
create(:ci_runner, :group, groups: [group], tag_list: %w[group_runner])
control = ActiveRecord::QueryRecorder.new { show }
# with several tags
create(:ci_runner, :instance, tag_list: %w[shared_runner tag2 tag3])
create(:ci_runner, :project, projects: [other_project], tag_list: %w[project_runner tag2 tag3])
create(:ci_runner, :group, groups: [group], tag_list: %w[group_runner tag2 tag3])
expect { show }.not_to exceed_query_limit(control)
end
end
end

View File

@ -0,0 +1,107 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Ci::RunnerProjectPolicy, feature_category: :runner do
using RSpec::Parameterized::TableSyntax
let_it_be(:project) { create(:project) }
let_it_be(:other_project) { create(:project) }
let_it_be(:developer_project) { create(:project) }
let_it_be(:runner) { create(:ci_runner, :project, projects: [project, other_project, developer_project]) }
let_it_be(:owner_runner_project) { project.runner_projects.first }
let_it_be(:member_runner_project) { other_project.runner_projects.first }
let_it_be(:developer_runner_project) { developer_project.runner_projects.first }
let_it_be(:owner) { create(:user, owner_of: [project, other_project]) }
let_it_be(:maintainer) { create(:user, maintainer_of: [project, other_project], developer_of: developer_project) }
let_it_be(:developer) { create(:user, developer_of: [project, other_project]) }
let_it_be(:reporter) { create(:user, reporter_of: [project, other_project]) }
let_it_be(:guest) { create(:user, guest_of: [project, other_project]) }
let_it_be(:non_member) { create(:user) }
let_it_be(:admin) { create(:admin) }
let_it_be(:anonymous) { nil }
let_it_be(:locked_runner) { create(:ci_runner, :project, :locked, projects: [project, other_project]) }
let_it_be(:locked_runner_project) { locked_runner.runner_projects.last }
let(:runner_project) { member_runner_project }
subject(:policy) { described_class.new(user, runner_project) }
describe 'ability :unassign_runner' do
shared_examples 'unassign_runner for user with project access' do |allowed:|
if allowed
it { is_expected.to be_allowed(:unassign_runner) }
else
it { is_expected.to be_disallowed(:unassign_runner) }
end
context 'when developer of project' do
let(:runner_project) { developer_runner_project }
it { is_expected.to be_disallowed(:unassign_runner) }
end
context 'with locked runner' do
let(:runner_project) { locked_runner_project }
it { is_expected.to be_disallowed(:unassign_runner) }
end
context 'with owner runner project (assigned to owner project)' do
let(:runner_project) { owner_runner_project }
it { is_expected.to be_disallowed(:unassign_runner) }
end
end
where(:user) do
[
ref(:anonymous),
ref(:non_member),
ref(:guest),
ref(:reporter),
ref(:developer)
]
end
with_them do
it { is_expected.to be_disallowed(:unassign_runner) }
end
context 'when user is maintainer' do
let(:user) { maintainer }
it_behaves_like 'unassign_runner for user with project access', allowed: true
end
context 'when user is owner' do
let(:user) { owner }
it_behaves_like 'unassign_runner for user with project access', allowed: true
end
context 'when user is admin' do
let(:user) { admin }
it { is_expected.to be_disallowed(:unassign_runner) }
context 'when admin mode is enabled', :enable_admin_mode do
it { is_expected.to be_allowed(:unassign_runner) }
context 'with locked runner' do
let(:runner_project) { locked_runner_project }
it { is_expected.to be_allowed(:unassign_runner) }
end
context 'with owner runner project (assigned to owner project)' do
let(:runner_project) { owner_runner_project }
it { is_expected.to be_disallowed(:unassign_runner) }
end
end
end
end
end

View File

@ -31,7 +31,7 @@ RSpec.describe Mutations::Ci::Runner::UnassignFromProject, feature_category: :ru
let(:mutation_response) { graphql_mutation_response(:runner_unassign_from_project) }
specify { expect(described_class).to require_graphql_authorizations(:admin_runners) }
specify { expect(described_class).to require_graphql_authorizations(:unassign_runner) }
context 'with invalid parameters' do
context 'when project_path is missing' do
@ -85,7 +85,8 @@ RSpec.describe Mutations::Ci::Runner::UnassignFromProject, feature_category: :ru
it 'returns an error' do
post_graphql_mutation(mutation, current_user: group_owner)
expect_graphql_errors_to_include("Runner does not exist or is not assigned to this project")
expect_graphql_errors_to_include("The resource that you are attempting to access does not exist or you " \
"don't have permission to perform this action")
end
end
end
@ -94,8 +95,8 @@ RSpec.describe Mutations::Ci::Runner::UnassignFromProject, feature_category: :ru
context 'when user does not have necessary permissions' do
it 'does not allow non-accessible user to unassign a runner from a project' do
post_graphql_mutation(mutation, current_user: non_accessible_user)
expect_graphql_errors_to_include("The resource that you are attempting to access does not exist or you " \
"don't have permission to perform this action")
expect(response).to have_gitlab_http_status(:success)
expect(mutation_response['errors']).to include("User not allowed to manage project's runners")
expect(runner.reload.projects).to include(project)
end
end
@ -139,8 +140,9 @@ RSpec.describe Mutations::Ci::Runner::UnassignFromProject, feature_category: :ru
it 'returns an error' do
post_graphql_mutation(mutation, current_user: group_owner)
expect(response).to have_gitlab_http_status(:success)
expect_graphql_errors_to_include("Runner does not exist or is not assigned to this project")
expect_graphql_errors_to_include("The resource that you are attempting to access does not exist or you " \
"don't have permission to perform this action")
expect(runner.reload.projects).to include(project)
end
end

View File

@ -21,7 +21,7 @@ RSpec.describe ::Ci::Runners::UnassignRunnerService, '#execute', :aggregate_fail
expect { execute }.not_to change { runner.runner_projects.count }.from(2)
expect(execute).to be_error
expect(execute.message).to eq('User not allowed to unassign runner')
expect(execute.message).to eq("User not allowed to manage project's runners")
end
end
@ -32,7 +32,7 @@ RSpec.describe ::Ci::Runners::UnassignRunnerService, '#execute', :aggregate_fail
expect(runner_project).not_to receive(:destroy)
expect(execute).to be_error
expect(execute.message).to eq('User not allowed to unassign runner')
expect(execute.message).to eq("User not allowed to manage project's runners")
end
end