Commit 6e280dce authored by Markus Koller's avatar Markus Koller

Merge branch '323708-agent-authorized-projects' into 'master'

Persist projects configured to use an Agent

See merge request gitlab-org/gitlab!67295
parents 325bec26 3a80bebf
......@@ -13,6 +13,9 @@ module Clusters
has_many :group_authorizations, class_name: 'Clusters::Agents::GroupAuthorization'
has_many :authorized_groups, class_name: '::Group', through: :group_authorizations, source: :group
has_many :project_authorizations, class_name: 'Clusters::Agents::ProjectAuthorization'
has_many :authorized_projects, class_name: '::Project', through: :project_authorizations, source: :project
scope :ordered_by_name, -> { order(:name) }
scope :with_name, -> (name) { where(name: name) }
......
# frozen_string_literal: true
module Clusters
module Agents
class ProjectAuthorization < ApplicationRecord
self.table_name = 'agent_project_authorizations'
belongs_to :agent, class_name: 'Clusters::Agent', optional: false
belongs_to :project, class_name: '::Project', optional: false
validates :config, json_schema: { filename: 'cluster_agent_authorization_configuration' }
end
end
end
......@@ -5,9 +5,10 @@ module Clusters
class RefreshAuthorizationService
include Gitlab::Utils::StrongMemoize
AUTHORIZED_GROUP_LIMIT = 100
AUTHORIZED_ENTITY_LIMIT = 100
delegate :project, to: :agent, private: true
delegate :root_ancestor, to: :project, private: true
def initialize(agent, config:)
@agent = agent
......@@ -15,6 +16,30 @@ module Clusters
end
def execute
refresh_projects!
refresh_groups!
true
end
private
attr_reader :agent, :config
def refresh_projects!
if allowed_project_configurations.present?
project_ids = allowed_project_configurations.map { |config| config.fetch(:project_id) }
agent.with_lock do
agent.project_authorizations.upsert_all(allowed_project_configurations, unique_by: [:agent_id, :project_id])
agent.project_authorizations.where.not(project_id: project_ids).delete_all # rubocop: disable CodeReuse/ActiveRecord
end
else
agent.project_authorizations.delete_all(:delete_all)
end
end
def refresh_groups!
if allowed_group_configurations.present?
group_ids = allowed_group_configurations.map { |config| config.fetch(:group_id) }
......@@ -25,35 +50,57 @@ module Clusters
else
agent.group_authorizations.delete_all(:delete_all)
end
true
end
private
def allowed_project_configurations
strong_memoize(:allowed_project_configurations) do
project_entries = extract_config_entries(entity: 'projects')
attr_reader :agent, :config
if project_entries
allowed_projects.where_full_path_in(project_entries.keys).map do |project|
{ project_id: project.id, config: project_entries[project.full_path] }
end
end
end
end
def allowed_group_configurations
strong_memoize(:allowed_group_configurations) do
group_entries = config.dig('ci_access', 'groups')&.first(AUTHORIZED_GROUP_LIMIT)
group_entries = extract_config_entries(entity: 'groups')
if group_entries
groups_by_path = group_entries.index_by { |config| config.delete('id') }
allowed_groups.where_full_path_in(groups_by_path.keys).map do |group|
{ group_id: group.id, config: groups_by_path[group.full_path] }
allowed_groups.where_full_path_in(group_entries.keys).map do |group|
{ group_id: group.id, config: group_entries[group.full_path] }
end
end
end
end
def extract_config_entries(entity:)
config.dig('ci_access', entity)
&.first(AUTHORIZED_ENTITY_LIMIT)
&.index_by { |config| config.delete('id') }
end
def allowed_projects
if group_root_ancestor?
root_ancestor.all_projects
else
::Project.none
end
end
def allowed_groups
if project.root_ancestor.group?
project.root_ancestor.self_and_descendants
if group_root_ancestor?
root_ancestor.self_and_descendants
else
::Group.none
end
end
def group_root_ancestor?
root_ancestor.group?
end
end
end
end
# frozen_string_literal: true
class CreateAgentProjectAuthorizations < Gitlab::Database::Migration[1.0]
def change
create_table :agent_project_authorizations do |t|
t.bigint :project_id, null: false
t.bigint :agent_id, null: false
t.jsonb :config, null: false
t.index :project_id
t.index [:agent_id, :project_id], unique: true
end
end
end
# frozen_string_literal: true
class AddAgentProjectAuthorizationsForeignKeys < Gitlab::Database::Migration[1.0]
disable_ddl_transaction!
def up
add_concurrent_foreign_key :agent_project_authorizations, :projects, column: :project_id
add_concurrent_foreign_key :agent_project_authorizations, :cluster_agents, column: :agent_id
end
def down
with_lock_retries do
remove_foreign_key_if_exists :agent_project_authorizations, column: :project_id
end
with_lock_retries do
remove_foreign_key_if_exists :agent_project_authorizations, column: :agent_id
end
end
end
71d51d1ac74f5c559bf41b23e5677af7228ba824da835afbe0f2299695912c19
\ No newline at end of file
172e77890657dd82c6ce770c286894731f6ef7fbcffb4b4fc97e0a41d132b8e8
\ No newline at end of file
......@@ -9652,6 +9652,22 @@ CREATE SEQUENCE agent_group_authorizations_id_seq
ALTER SEQUENCE agent_group_authorizations_id_seq OWNED BY agent_group_authorizations.id;
CREATE TABLE agent_project_authorizations (
id bigint NOT NULL,
project_id bigint NOT NULL,
agent_id bigint NOT NULL,
config jsonb NOT NULL
);
CREATE SEQUENCE agent_project_authorizations_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
ALTER SEQUENCE agent_project_authorizations_id_seq OWNED BY agent_project_authorizations.id;
CREATE TABLE alert_management_alert_assignees (
id bigint NOT NULL,
user_id bigint NOT NULL,
......@@ -20862,6 +20878,8 @@ ALTER TABLE ONLY abuse_reports ALTER COLUMN id SET DEFAULT nextval('abuse_report
ALTER TABLE ONLY agent_group_authorizations ALTER COLUMN id SET DEFAULT nextval('agent_group_authorizations_id_seq'::regclass);
ALTER TABLE ONLY agent_project_authorizations ALTER COLUMN id SET DEFAULT nextval('agent_project_authorizations_id_seq'::regclass);
ALTER TABLE ONLY alert_management_alert_assignees ALTER COLUMN id SET DEFAULT nextval('alert_management_alert_assignees_id_seq'::regclass);
ALTER TABLE ONLY alert_management_alert_user_mentions ALTER COLUMN id SET DEFAULT nextval('alert_management_alert_user_mentions_id_seq'::regclass);
......@@ -22195,6 +22213,9 @@ ALTER TABLE ONLY abuse_reports
ALTER TABLE ONLY agent_group_authorizations
ADD CONSTRAINT agent_group_authorizations_pkey PRIMARY KEY (id);
ALTER TABLE ONLY agent_project_authorizations
ADD CONSTRAINT agent_project_authorizations_pkey PRIMARY KEY (id);
ALTER TABLE ONLY alert_management_alert_assignees
ADD CONSTRAINT alert_management_alert_assignees_pkey PRIMARY KEY (id);
......@@ -24141,6 +24162,10 @@ CREATE UNIQUE INDEX index_agent_group_authorizations_on_agent_id_and_group_id ON
CREATE INDEX index_agent_group_authorizations_on_group_id ON agent_group_authorizations USING btree (group_id);
CREATE UNIQUE INDEX index_agent_project_authorizations_on_agent_id_and_project_id ON agent_project_authorizations USING btree (agent_id, project_id);
CREATE INDEX index_agent_project_authorizations_on_project_id ON agent_project_authorizations USING btree (project_id);
CREATE INDEX index_alert_assignees_on_alert_id ON alert_management_alert_assignees USING btree (alert_id);
CREATE UNIQUE INDEX index_alert_assignees_on_user_id_and_alert_id ON alert_management_alert_assignees USING btree (user_id, alert_id);
......@@ -27423,6 +27448,9 @@ ALTER TABLE ONLY analytics_devops_adoption_segments
ALTER TABLE ONLY user_details
ADD CONSTRAINT fk_190e4fcc88 FOREIGN KEY (provisioned_by_group_id) REFERENCES namespaces(id) ON DELETE SET NULL;
ALTER TABLE ONLY agent_project_authorizations
ADD CONSTRAINT fk_1d30bb4987 FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE;
ALTER TABLE ONLY vulnerabilities
ADD CONSTRAINT fk_1d37cddf91 FOREIGN KEY (epic_id) REFERENCES epics(id) ON DELETE SET NULL;
......@@ -27906,6 +27934,9 @@ ALTER TABLE ONLY external_status_checks_protected_branches
ALTER TABLE ONLY issue_assignees
ADD CONSTRAINT fk_b7d881734a FOREIGN KEY (issue_id) REFERENCES issues(id) ON DELETE CASCADE;
ALTER TABLE ONLY agent_project_authorizations
ADD CONSTRAINT fk_b7fe9b4777 FOREIGN KEY (agent_id) REFERENCES cluster_agents(id) ON DELETE CASCADE;
ALTER TABLE ONLY ci_trigger_requests
ADD CONSTRAINT fk_b8ec8b7245 FOREIGN KEY (trigger_id) REFERENCES ci_triggers(id) ON DELETE CASCADE;
# frozen_string_literal: true
FactoryBot.define do
factory :agent_project_authorization, class: 'Clusters::Agents::ProjectAuthorization' do
association :agent, factory: :cluster_agent
project
config { { default_namespace: 'production' } }
end
end
......@@ -11,6 +11,8 @@ RSpec.describe Clusters::Agent do
it { is_expected.to have_many(:last_used_agent_tokens).class_name('Clusters::AgentToken') }
it { is_expected.to have_many(:group_authorizations).class_name('Clusters::Agents::GroupAuthorization') }
it { is_expected.to have_many(:authorized_groups).through(:group_authorizations) }
it { is_expected.to have_many(:project_authorizations).class_name('Clusters::Agents::ProjectAuthorization') }
it { is_expected.to have_many(:authorized_projects).through(:project_authorizations).class_name('::Project') }
it { is_expected.to validate_presence_of(:name) }
it { is_expected.to validate_length_of(:name).is_at_most(63) }
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Clusters::Agents::ProjectAuthorization do
it { is_expected.to belong_to(:agent).class_name('Clusters::Agent').required }
it { is_expected.to belong_to(:project).class_name('Project').required }
it { expect(described_class).to validate_jsonb_schema(['config']) }
end
......@@ -106,6 +106,9 @@ RSpec.describe API::Internal::Kubernetes do
ci_access: {
groups: [
{ id: group.full_path, default_namespace: 'production' }
],
projects: [
{ id: project.full_path, default_namespace: 'staging' }
]
}
}
......@@ -119,6 +122,7 @@ RSpec.describe API::Internal::Kubernetes do
expect(response).to have_gitlab_http_status(:no_content)
expect(agent.authorized_groups).to contain_exactly(group)
expect(agent.authorized_projects).to contain_exactly(project)
end
end
......
......@@ -5,10 +5,15 @@ require 'spec_helper'
RSpec.describe Clusters::Agents::RefreshAuthorizationService do
describe '#execute' do
let_it_be(:root_ancestor) { create(:group) }
let_it_be(:removed_group) { create(:group, parent: root_ancestor) }
let_it_be(:modified_group) { create(:group, parent: root_ancestor) }
let_it_be(:added_group) { create(:group, parent: root_ancestor) }
let_it_be(:removed_project) { create(:project, namespace: root_ancestor) }
let_it_be(:modified_project) { create(:project, namespace: root_ancestor) }
let_it_be(:added_project) { create(:project, namespace: root_ancestor) }
let(:project) { create(:project, namespace: root_ancestor) }
let(:agent) { create(:cluster_agent, project: project) }
......@@ -18,6 +23,10 @@ RSpec.describe Clusters::Agents::RefreshAuthorizationService do
groups: [
{ id: added_group.full_path, default_namespace: 'default' },
{ id: modified_group.full_path, default_namespace: 'new-namespace' }
],
projects: [
{ id: added_project.full_path, default_namespace: 'default' },
{ id: modified_project.full_path, default_namespace: 'new-namespace' }
]
}
}.deep_stringify_keys
......@@ -30,54 +39,93 @@ RSpec.describe Clusters::Agents::RefreshAuthorizationService do
agent.group_authorizations.create!(group: removed_group, config: default_config)
agent.group_authorizations.create!(group: modified_group, config: default_config)
agent.project_authorizations.create!(project: removed_project, config: default_config)
agent.project_authorizations.create!(project: modified_project, config: default_config)
end
it 'refreshes authorizations for the agent' do
expect(subject).to be_truthy
expect(agent.authorized_groups).to contain_exactly(added_group, modified_group)
shared_examples 'removing authorization' do
context 'config contains no groups' do
let(:config) { {} }
added_authorization = agent.group_authorizations.find_by(group: added_group)
expect(added_authorization.config).to eq({ 'default_namespace' => 'default' })
it 'removes all authorizations' do
expect(subject).to be_truthy
expect(authorizations).to be_empty
end
end
modified_authorization = agent.group_authorizations.find_by(group: modified_group)
expect(modified_authorization.config).to eq({ 'default_namespace' => 'new-namespace' })
end
context 'config contains groups outside of the configuration project hierarchy' do
let(:project) { create(:project, namespace: create(:group)) }
context 'config contains no groups' do
let(:config) { {} }
it 'removes all authorizations' do
expect(subject).to be_truthy
expect(authorizations).to be_empty
end
end
it 'removes all authorizations' do
expect(subject).to be_truthy
expect(agent.authorized_groups).to be_empty
context 'configuration project does not belong to a group' do
let(:project) { create(:project) }
it 'removes all authorizations' do
expect(subject).to be_truthy
expect(authorizations).to be_empty
end
end
end
context 'config contains groups outside of the configuration project hierarchy' do
let(:project) { create(:project, namespace: create(:group)) }
it 'removes all authorizations' do
describe 'group authorization' do
it 'refreshes authorizations for the agent' do
expect(subject).to be_truthy
expect(agent.authorized_groups).to be_empty
expect(agent.authorized_groups).to contain_exactly(added_group, modified_group)
added_authorization = agent.group_authorizations.find_by(group: added_group)
expect(added_authorization.config).to eq({ 'default_namespace' => 'default' })
modified_authorization = agent.group_authorizations.find_by(group: modified_group)
expect(modified_authorization.config).to eq({ 'default_namespace' => 'new-namespace' })
end
end
context 'configuration project does not belong to a group' do
let(:project) { create(:project) }
context 'config contains too many groups' do
before do
stub_const("#{described_class}::AUTHORIZED_ENTITY_LIMIT", 1)
end
it 'removes all authorizations' do
expect(subject).to be_truthy
expect(agent.authorized_groups).to be_empty
it 'authorizes groups up to the limit' do
expect(subject).to be_truthy
expect(agent.authorized_groups).to contain_exactly(added_group)
end
end
end
context 'config contains too many groups' do
before do
stub_const("#{described_class}::AUTHORIZED_GROUP_LIMIT", 1)
include_examples 'removing authorization' do
let(:authorizations) { agent.authorized_groups }
end
end
it 'authorizes groups up to the limit' do
describe 'project authorization' do
it 'refreshes authorizations for the agent' do
expect(subject).to be_truthy
expect(agent.authorized_groups).to contain_exactly(added_group)
expect(agent.authorized_projects).to contain_exactly(added_project, modified_project)
added_authorization = agent.project_authorizations.find_by(project: added_project)
expect(added_authorization.config).to eq({ 'default_namespace' => 'default' })
modified_authorization = agent.project_authorizations.find_by(project: modified_project)
expect(modified_authorization.config).to eq({ 'default_namespace' => 'new-namespace' })
end
context 'config contains too many projects' do
before do
stub_const("#{described_class}::AUTHORIZED_ENTITY_LIMIT", 1)
end
it 'authorizes projects up to the limit' do
expect(subject).to be_truthy
expect(agent.authorized_projects).to contain_exactly(added_project)
end
end
include_examples 'removing authorization' do
let(:authorizations) { agent.authorized_projects }
end
end
end
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment