mirror of
https://gitlab.com/gitlab-org/gitlab-foss.git
synced 2025-07-23 00:47:51 +00:00
Update from merge request
This commit is contained in:
@ -1 +1 @@
|
||||
2b6810dc5688ebdbf4024b4885fdc59686a3f1fa
|
||||
47335750c521ec469a5074962fb28b48b9dc06a6
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
23
app/policies/ci/runner_project_policy.rb
Normal file
23
app/policies/ci/runner_project_policy.rb
Normal 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
|
@ -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
|
||||
|
@ -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'
|
||||
|
@ -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
|
1
db/schema_migrations/20250717232204
Normal file
1
db/schema_migrations/20250717232204
Normal file
@ -0,0 +1 @@
|
||||
243d4617b21078c793fae163d28ab38a74cded2dde6a1c72cf83ecc0a2150600
|
@ -2,6 +2,7 @@ accessor
|
||||
accessors
|
||||
ACLs
|
||||
Adafruit
|
||||
Agentic
|
||||
Airbnb
|
||||
Airtable
|
||||
Akismet
|
||||
|
@ -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**:
|
||||
|
||||
|
@ -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.
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
107
spec/policies/ci/runner_project_policy_spec.rb
Normal file
107
spec/policies/ci/runner_project_policy_spec.rb
Normal 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
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
Reference in New Issue
Block a user