From 72c6be2827fe5bc73cf1041ebd3b29f539d4c92e Mon Sep 17 00:00:00 2001 From: Niklas van Schrick Date: Sun, 23 Jun 2024 01:43:15 +0200 Subject: [PATCH] Allow role permissions to be restricted to specific projects --- .../namespace_roles/assign_projects.rb | 30 +++++ app/graphql/types/mutation_type.rb | 1 + app/graphql/types/namespace_role_type.rb | 3 + app/models/audit_event.rb | 1 + app/models/namespace_project.rb | 6 + app/models/namespace_role.rb | 11 ++ app/models/namespace_role_ability.rb | 1 + .../namespace_role_project_assignment.rb | 6 + .../concerns/customizable_permission.rb | 9 +- app/policies/namespace_policy.rb | 1 + .../assign_projects_service.rb | 61 +++++++++ ...eate_namespace_role_project_assignments.rb | 14 +++ db/schema_migrations/20240622182317 | 1 + db/structure.sql | 32 +++++ .../graphql/enum/namespaceroleability.md | 1 + .../mutation/namespacerolesassignprojects.md | 21 ++++ docs/content/graphql/object/namespacerole.md | 1 + .../namespace_role_project_assignments.rb | 8 ++ .../namespace_roles/assign_projects_spec.rb | 7 ++ .../graphql/types/namespace_role_type_spec.rb | 1 + spec/models/namespace_project_spec.rb | 8 ++ .../namespace_role_project_assignment_spec.rb | 12 ++ spec/models/namespace_role_spec.rb | 8 ++ .../concerns/customizable_permission_spec.rb | 32 +++++ .../assign_projects_mutation_spec.rb | 80 ++++++++++++ .../assign_projects_service_spec.rb | 118 ++++++++++++++++++ spec/support/helpers/stub_ability.rb | 6 + 27 files changed, 476 insertions(+), 4 deletions(-) create mode 100644 app/graphql/mutations/namespace_roles/assign_projects.rb create mode 100644 app/models/namespace_role_project_assignment.rb create mode 100644 app/services/namespace_roles/assign_projects_service.rb create mode 100644 db/migrate/20240622182317_create_namespace_role_project_assignments.rb create mode 100644 db/schema_migrations/20240622182317 create mode 100644 docs/content/graphql/mutation/namespacerolesassignprojects.md create mode 100644 spec/factories/namespace_role_project_assignments.rb create mode 100644 spec/graphql/mutations/namespace_roles/assign_projects_spec.rb create mode 100644 spec/models/namespace_role_project_assignment_spec.rb create mode 100644 spec/requests/graphql/mutation/namespace_roles/assign_projects_mutation_spec.rb create mode 100644 spec/services/namespace_roles/assign_projects_service_spec.rb diff --git a/app/graphql/mutations/namespace_roles/assign_projects.rb b/app/graphql/mutations/namespace_roles/assign_projects.rb new file mode 100644 index 00000000..7e0b60d9 --- /dev/null +++ b/app/graphql/mutations/namespace_roles/assign_projects.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Mutations + module NamespaceRoles + class AssignProjects < BaseMutation + description 'Update the project a role is assigned to.' + + field :projects, [Types::NamespaceProjectType], description: 'The now assigned projects' + + argument :project_ids, [Types::GlobalIdType[::NamespaceProject]], + description: 'The projects that should be assigned to the role' + argument :role_id, Types::GlobalIdType[::NamespaceRole], + description: 'The id of the role which should be assigned to projects' + + def resolve(role_id:, project_ids:) + role = SagittariusSchema.object_from_id(role_id) + projects = project_ids.map { |id| SagittariusSchema.object_from_id(id) } + + return { projects: nil, errors: [create_message_error('Invalid role')] } if role.nil? + return { projects: nil, errors: [create_message_error('Invalid project')] } if projects.any?(&:nil?) + + ::NamespaceRoles::AssignProjectsService.new( + current_user, + role, + projects + ).execute.to_mutation_response(success_key: :projects) + end + end + end +end diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index 7028adec..017180bd 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -14,6 +14,7 @@ class MutationType < Types::BaseObject mount_mutation Mutations::NamespaceProjects::Update mount_mutation Mutations::NamespaceProjects::Delete mount_mutation Mutations::NamespaceRoles::AssignAbilities + mount_mutation Mutations::NamespaceRoles::AssignProjects mount_mutation Mutations::NamespaceRoles::Create mount_mutation Mutations::NamespaceRoles::Delete mount_mutation Mutations::NamespaceRoles::Update diff --git a/app/graphql/types/namespace_role_type.rb b/app/graphql/types/namespace_role_type.rb index 68ea1aef..63e5084e 100644 --- a/app/graphql/types/namespace_role_type.rb +++ b/app/graphql/types/namespace_role_type.rb @@ -12,6 +12,9 @@ class NamespaceRoleType < BaseObject field :namespace, Types::NamespaceType, null: true, description: 'The namespace where this role belongs to' + field :assigned_projects, Types::NamespaceProjectType.connection_type, + description: 'The projects this role is assigned to' + id_field ::NamespaceRole timestamps diff --git a/app/models/audit_event.rb b/app/models/audit_event.rb index 18d6b004..a9d86e0b 100644 --- a/app/models/audit_event.rb +++ b/app/models/audit_event.rb @@ -24,6 +24,7 @@ class AuditEvent < ApplicationRecord runtime_updated: 20, runtime_deleted: 21, runtime_token_rotated: 22, + namespace_role_projects_updated: 23, }.with_indifferent_access # rubocop:disable Lint/StructNewOverride diff --git a/app/models/namespace_project.rb b/app/models/namespace_project.rb index f474368f..a606172f 100644 --- a/app/models/namespace_project.rb +++ b/app/models/namespace_project.rb @@ -3,6 +3,12 @@ class NamespaceProject < ApplicationRecord belongs_to :namespace, inverse_of: :projects + has_many :role_assignments, class_name: 'NamespaceRoleProjectAssignment', + inverse_of: :project + has_many :assigned_roles, class_name: 'NamespaceRole', through: :role_assignments, + inverse_of: :assigned_projects, + source: :project + validates :name, presence: true, length: { minimum: 3, maximum: 50 }, allow_blank: false, diff --git a/app/models/namespace_role.rb b/app/models/namespace_role.rb index 914ec08a..cde3c540 100644 --- a/app/models/namespace_role.rb +++ b/app/models/namespace_role.rb @@ -6,6 +6,17 @@ class NamespaceRole < ApplicationRecord has_many :abilities, class_name: 'NamespaceRoleAbility', inverse_of: :namespace_role has_many :member_roles, class_name: 'NamespaceMemberRole', inverse_of: :role has_many :members, class_name: 'NamespaceMember', through: :member_roles, inverse_of: :roles + has_many :project_assignments, class_name: 'NamespaceRoleProjectAssignment', inverse_of: :role + has_many :assigned_projects, class_name: 'NamespaceProject', + through: :project_assignments, + inverse_of: :assigned_roles, + source: :role + + scope :applicable_to_project, lambda { |project| + left_joins(:project_assignments) + .where(project_assignments: { project: project }) + .or(where.missing(:project_assignments)) + } validates :name, presence: true, length: { minimum: 3, maximum: 50 }, diff --git a/app/models/namespace_role_ability.rb b/app/models/namespace_role_ability.rb index 4e6579f7..abd365de 100644 --- a/app/models/namespace_role_ability.rb +++ b/app/models/namespace_role_ability.rb @@ -23,6 +23,7 @@ class NamespaceRoleAbility < ApplicationRecord update_runtime: { db: 19, description: 'Allows to update a runtime globally or for the namespace' }, delete_runtime: { db: 20, description: 'Allows to delete a runtime' }, rotate_runtime_token: { db: 21, description: 'Allows to regenerate a runtime token' }, + assign_role_projects: { db: 22, description: 'Allows to change the assigned projects of a namespace role' }, }.with_indifferent_access enum :ability, ABILITIES.transform_values { |v| v[:db] }, prefix: :can diff --git a/app/models/namespace_role_project_assignment.rb b/app/models/namespace_role_project_assignment.rb new file mode 100644 index 00000000..64ae21dd --- /dev/null +++ b/app/models/namespace_role_project_assignment.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +class NamespaceRoleProjectAssignment < ApplicationRecord + belongs_to :role, class_name: 'NamespaceRole', inverse_of: :project_assignments + belongs_to :project, class_name: 'NamespaceProject', inverse_of: :role_assignments +end diff --git a/app/policies/concerns/customizable_permission.rb b/app/policies/concerns/customizable_permission.rb index 0d95c8db..ce92fccd 100644 --- a/app/policies/concerns/customizable_permission.rb +++ b/app/policies/concerns/customizable_permission.rb @@ -31,10 +31,11 @@ def namespace_member(user, subject) def user_has_ability?(ability, user, subject) return false if namespace_member(user, subject).nil? - namespace_member(user, subject) - .roles - .joins(:abilities) - .exists?(namespace_role_abilities: { ability: ability }) + roles = namespace_member(user, subject).roles + + roles = roles.applicable_to_project(subject) if subject.is_a?(NamespaceProject) + + roles.joins(:abilities).exists?(namespace_role_abilities: { ability: ability }) end end end diff --git a/app/policies/namespace_policy.rb b/app/policies/namespace_policy.rb index 6377c6ae..70a62c4b 100644 --- a/app/policies/namespace_policy.rb +++ b/app/policies/namespace_policy.rb @@ -28,6 +28,7 @@ class NamespacePolicy < BasePolicy customizable_permission :update_runtime customizable_permission :delete_runtime customizable_permission :rotate_runtime_token + customizable_permission :assign_role_projects end NamespacePolicy.prepend_extensions diff --git a/app/services/namespace_roles/assign_projects_service.rb b/app/services/namespace_roles/assign_projects_service.rb new file mode 100644 index 00000000..ab89365b --- /dev/null +++ b/app/services/namespace_roles/assign_projects_service.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +module NamespaceRoles + class AssignProjectsService + include Sagittarius::Database::Transactional + + attr_reader :current_user, :role, :projects + + def initialize(current_user, role, projects) + @current_user = current_user + @role = role + @projects = projects + end + + # rubocop:disable Metrics/CyclomaticComplexity + # rubocop:disable Metrics/PerceivedComplexity + def execute + namespace = role.namespace + unless Ability.allowed?(current_user, :assign_role_projects, namespace) + return ServiceResponse.error(message: 'Missing permissions', payload: :missing_permission) + end + + transactional do |t| + current_projects = role.project_assignments + old_projects_for_audit_event = current_projects.map(&:project).map do |project| + { name: project.name, id: project.id } + end + + current_projects.where.not(project: projects).delete_all + + (projects - current_projects.map(&:project)).map do |projects| + project_assignment = NamespaceRoleProjectAssignment.create(role: role, project: projects) + + next if project_assignment.persisted? + + t.rollback_and_return! ServiceResponse.error( + message: 'Failed to save namespace role project assignment', + payload: project_assignment.errors + ) + end + + new_projects = role.reload.project_assignments.map(&:project) + + AuditService.audit( + :namespace_role_projects_updated, + author_id: current_user.id, + entity: role, + details: { + old_projects: old_projects_for_audit_event, + new_projects: new_projects.map { |project| { name: project.name, id: project.id } }, + }, + target: namespace + ) + + ServiceResponse.success(message: 'Role project assignments updated', payload: new_projects) + end + end + # rubocop:enable Metrics/PerceivedComplexity + # rubocop:enable Metrics/CyclomaticComplexity + end +end diff --git a/db/migrate/20240622182317_create_namespace_role_project_assignments.rb b/db/migrate/20240622182317_create_namespace_role_project_assignments.rb new file mode 100644 index 00000000..4c2ba88a --- /dev/null +++ b/db/migrate/20240622182317_create_namespace_role_project_assignments.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class CreateNamespaceRoleProjectAssignments < Sagittarius::Database::Migration[1.0] + def change + create_table :namespace_role_project_assignments do |t| + t.references :role, null: false, index: false, foreign_key: { to_table: :namespace_roles } + t.references :project, null: false, foreign_key: { to_table: :namespace_projects } + + t.index %i[role_id project_id], unique: true + + t.timestamps_with_timezone + end + end +end diff --git a/db/schema_migrations/20240622182317 b/db/schema_migrations/20240622182317 new file mode 100644 index 00000000..249dc6f1 --- /dev/null +++ b/db/schema_migrations/20240622182317 @@ -0,0 +1 @@ +6e5cf40f7ab5a1e704fe918addc71ae146439d096a1183734b725b986ea5fdea \ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index 1b17aceb..00ffcc6a 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -208,6 +208,23 @@ CREATE SEQUENCE namespace_role_abilities_id_seq ALTER SEQUENCE namespace_role_abilities_id_seq OWNED BY namespace_role_abilities.id; +CREATE TABLE namespace_role_project_assignments ( + id bigint NOT NULL, + role_id bigint NOT NULL, + project_id bigint NOT NULL, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL +); + +CREATE SEQUENCE namespace_role_project_assignments_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE namespace_role_project_assignments_id_seq OWNED BY namespace_role_project_assignments.id; + CREATE TABLE namespace_roles ( id bigint NOT NULL, name text NOT NULL, @@ -341,6 +358,8 @@ ALTER TABLE ONLY namespace_projects ALTER COLUMN id SET DEFAULT nextval('namespa ALTER TABLE ONLY namespace_role_abilities ALTER COLUMN id SET DEFAULT nextval('namespace_role_abilities_id_seq'::regclass); +ALTER TABLE ONLY namespace_role_project_assignments ALTER COLUMN id SET DEFAULT nextval('namespace_role_project_assignments_id_seq'::regclass); + ALTER TABLE ONLY namespace_roles ALTER COLUMN id SET DEFAULT nextval('namespace_roles_id_seq'::regclass); ALTER TABLE ONLY namespaces ALTER COLUMN id SET DEFAULT nextval('namespaces_id_seq'::regclass); @@ -392,6 +411,9 @@ ALTER TABLE ONLY namespace_projects ALTER TABLE ONLY namespace_role_abilities ADD CONSTRAINT namespace_role_abilities_pkey PRIMARY KEY (id); +ALTER TABLE ONLY namespace_role_project_assignments + ADD CONSTRAINT namespace_role_project_assignments_pkey PRIMARY KEY (id); + ALTER TABLE ONLY namespace_roles ADD CONSTRAINT namespace_roles_pkey PRIMARY KEY (id); @@ -415,6 +437,8 @@ ALTER TABLE ONLY users CREATE UNIQUE INDEX idx_on_namespace_role_id_ability_a092da8841 ON namespace_role_abilities USING btree (namespace_role_id, ability); +CREATE UNIQUE INDEX idx_on_role_id_project_id_5d4b5917dc ON namespace_role_project_assignments USING btree (role_id, project_id); + CREATE UNIQUE INDEX index_application_settings_on_setting ON application_settings USING btree (setting); CREATE INDEX index_audit_events_on_author_id ON audit_events USING btree (author_id); @@ -465,6 +489,8 @@ CREATE INDEX index_namespace_members_on_user_id ON namespace_members USING btree CREATE INDEX index_namespace_projects_on_namespace_id ON namespace_projects USING btree (namespace_id); +CREATE INDEX index_namespace_role_project_assignments_on_project_id ON namespace_role_project_assignments USING btree (project_id); + CREATE UNIQUE INDEX "index_namespace_roles_on_namespace_id_LOWER_name" ON namespace_roles USING btree (namespace_id, lower(name)); CREATE UNIQUE INDEX index_namespaces_on_parent_id_and_parent_type ON namespaces USING btree (parent_id, parent_type); @@ -495,6 +521,12 @@ ALTER TABLE ONLY namespace_members ALTER TABLE ONLY namespace_member_roles ADD CONSTRAINT fk_rails_585a684166 FOREIGN KEY (role_id) REFERENCES namespace_roles(id) ON DELETE CASCADE; +ALTER TABLE ONLY namespace_role_project_assignments + ADD CONSTRAINT fk_rails_623f8a5b72 FOREIGN KEY (role_id) REFERENCES namespace_roles(id); + +ALTER TABLE ONLY namespace_role_project_assignments + ADD CONSTRAINT fk_rails_69066bda8f FOREIGN KEY (project_id) REFERENCES namespace_projects(id); + ALTER TABLE ONLY namespace_member_roles ADD CONSTRAINT fk_rails_6c0d5a04c4 FOREIGN KEY (member_id) REFERENCES namespace_members(id) ON DELETE CASCADE; diff --git a/docs/content/graphql/enum/namespaceroleability.md b/docs/content/graphql/enum/namespaceroleability.md index f31ee0ca..c427bfc9 100644 --- a/docs/content/graphql/enum/namespaceroleability.md +++ b/docs/content/graphql/enum/namespaceroleability.md @@ -8,6 +8,7 @@ Represents abilities that can be granted to roles in namespaces. |-------|-------------| | `ASSIGN_MEMBER_ROLES` | Allows to change the roles of a namespace member | | `ASSIGN_ROLE_ABILITIES` | Allows to change the abilities of a namespace role | +| `ASSIGN_ROLE_PROJECTS` | Allows to change the assigned projects of a namespace role | | `CREATE_NAMESPACE_LICENSE` | Allows to create a license for the namespace | | `CREATE_NAMESPACE_PROJECT` | Allows to create a project in the namespace | | `CREATE_NAMESPACE_ROLE` | Allows the creation of roles in a namespace | diff --git a/docs/content/graphql/mutation/namespacerolesassignprojects.md b/docs/content/graphql/mutation/namespacerolesassignprojects.md new file mode 100644 index 00000000..9a5055f3 --- /dev/null +++ b/docs/content/graphql/mutation/namespacerolesassignprojects.md @@ -0,0 +1,21 @@ +--- +title: namespaceRolesAssignProjects +--- + +Update the project a role is assigned to. + +## Arguments + +| Name | Type | Description | +|------|------|-------------| +| `clientMutationId` | [`String`](../scalar/string.md) | A unique identifier for the client performing the mutation. | +| `projectIds` | [`[NamespaceProjectID!]!`](../scalar/namespaceprojectid.md) | The projects that should be assigned to the role | +| `roleId` | [`NamespaceRoleID!`](../scalar/namespaceroleid.md) | The id of the role which should be assigned to projects | + +## Fields + +| Name | Type | Description | +|------|------|-------------| +| `clientMutationId` | [`String`](../scalar/string.md) | A unique identifier for the client performing the mutation. | +| `errors` | [`[Error!]!`](../union/error.md) | Errors encountered during execution of the mutation. | +| `projects` | [`[NamespaceProject!]`](../object/namespaceproject.md) | The now assigned projects | diff --git a/docs/content/graphql/object/namespacerole.md b/docs/content/graphql/object/namespacerole.md index d8fcc82d..c036d508 100644 --- a/docs/content/graphql/object/namespacerole.md +++ b/docs/content/graphql/object/namespacerole.md @@ -9,6 +9,7 @@ Represents a namespace role. | Name | Type | Description | |------|------|-------------| | `abilities` | [`[NamespaceRoleAbility!]!`](../enum/namespaceroleability.md) | The abilities the role is granted | +| `assignedProjects` | [`NamespaceProjectConnection`](../object/namespaceprojectconnection.md) | The projects this role is assigned to | | `createdAt` | [`Time!`](../scalar/time.md) | Time when this NamespaceRole was created | | `id` | [`NamespaceRoleID!`](../scalar/namespaceroleid.md) | Global ID of this NamespaceRole | | `name` | [`String!`](../scalar/string.md) | The name of this role | diff --git a/spec/factories/namespace_role_project_assignments.rb b/spec/factories/namespace_role_project_assignments.rb new file mode 100644 index 00000000..b62d6cb8 --- /dev/null +++ b/spec/factories/namespace_role_project_assignments.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :namespace_role_project_assignment do + role factory: :namespace_role + project factory: :namespace_project + end +end diff --git a/spec/graphql/mutations/namespace_roles/assign_projects_spec.rb b/spec/graphql/mutations/namespace_roles/assign_projects_spec.rb new file mode 100644 index 00000000..ffeda695 --- /dev/null +++ b/spec/graphql/mutations/namespace_roles/assign_projects_spec.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Mutations::NamespaceRoles::AssignProjects do + it { expect(described_class.graphql_name).to eq('NamespaceRolesAssignProjects') } +end diff --git a/spec/graphql/types/namespace_role_type_spec.rb b/spec/graphql/types/namespace_role_type_spec.rb index 2a3b0901..70a032a5 100644 --- a/spec/graphql/types/namespace_role_type_spec.rb +++ b/spec/graphql/types/namespace_role_type_spec.rb @@ -9,6 +9,7 @@ namespace name abilities + assignedProjects createdAt updatedAt ] diff --git a/spec/models/namespace_project_spec.rb b/spec/models/namespace_project_spec.rb index f7b9e7b0..7f581a85 100644 --- a/spec/models/namespace_project_spec.rb +++ b/spec/models/namespace_project_spec.rb @@ -7,6 +7,14 @@ describe 'associations' do it { is_expected.to belong_to(:namespace).required } + it { is_expected.to have_many(:role_assignments).class_name('NamespaceRoleProjectAssignment').inverse_of(:project) } + + it do + is_expected.to have_many(:assigned_roles).class_name('NamespaceRole') + .through(:role_assignments) + .source(:project) + .inverse_of(:assigned_projects) + end end describe 'validations' do diff --git a/spec/models/namespace_role_project_assignment_spec.rb b/spec/models/namespace_role_project_assignment_spec.rb new file mode 100644 index 00000000..e560c762 --- /dev/null +++ b/spec/models/namespace_role_project_assignment_spec.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe NamespaceRoleProjectAssignment do + subject { create(:namespace_role_project_assignment) } + + describe 'associations' do + it { is_expected.to belong_to(:role).class_name('NamespaceRole').inverse_of(:project_assignments).required } + it { is_expected.to belong_to(:project).class_name('NamespaceProject').inverse_of(:role_assignments).required } + end +end diff --git a/spec/models/namespace_role_spec.rb b/spec/models/namespace_role_spec.rb index 97a6116e..977f79fe 100644 --- a/spec/models/namespace_role_spec.rb +++ b/spec/models/namespace_role_spec.rb @@ -9,6 +9,14 @@ it { is_expected.to belong_to(:namespace).required } it { is_expected.to have_many(:member_roles).class_name('NamespaceMemberRole').inverse_of(:role) } it { is_expected.to have_many(:members).class_name('NamespaceMember').through(:member_roles).inverse_of(:roles) } + it { is_expected.to have_many(:project_assignments).class_name('NamespaceRoleProjectAssignment').inverse_of(:role) } + + it do + is_expected.to have_many(:assigned_projects).class_name('NamespaceProject') + .through(:project_assignments) + .source(:role) + .inverse_of(:assigned_roles) + end end describe 'validations' do diff --git a/spec/policies/concerns/customizable_permission_spec.rb b/spec/policies/concerns/customizable_permission_spec.rb index b68c34f6..dbb63df2 100644 --- a/spec/policies/concerns/customizable_permission_spec.rb +++ b/spec/policies/concerns/customizable_permission_spec.rb @@ -47,5 +47,37 @@ it { is_expected.not_to be_allowed(:invite_member) } end + context 'when role is assigned to projects' do + subject { policy_class.new(current_user, project) } + + let(:policy_class) do + Class.new(BasePolicy) do + include CustomizablePermission + + namespace_resolver(&:namespace) + + customizable_permission :update_namespace_project + end + end + let(:project) { create(:namespace_project, namespace: namespace) } + + before do + create(:namespace_role_project_assignment, role: namespace_role, project: assigned_project) + create(:namespace_role_ability, namespace_role: namespace_role, ability: :update_namespace_project) + end + + context 'when checking on the assigned project' do + let(:assigned_project) { project } + + it { is_expected.to be_allowed(:update_namespace_project) } + end + + context 'when checking on another project' do + let(:assigned_project) { create(:namespace_project, namespace: namespace) } + + it { is_expected.not_to be_allowed(:update_namespace_project) } + end + end + it { is_expected.not_to be_allowed(:create_namespace_role) } end diff --git a/spec/requests/graphql/mutation/namespace_roles/assign_projects_mutation_spec.rb b/spec/requests/graphql/mutation/namespace_roles/assign_projects_mutation_spec.rb new file mode 100644 index 00000000..40ee471a --- /dev/null +++ b/spec/requests/graphql/mutation/namespace_roles/assign_projects_mutation_spec.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'namespaceRolesAssignProjects Mutation' do + include GraphqlHelpers + + subject(:mutate!) { post_graphql mutation, variables: variables, current_user: current_user } + + let(:mutation) do + <<~QUERY + mutation($input: NamespaceRolesAssignProjectsInput!) { + namespaceRolesAssignProjects(input: $input) { + #{error_query} + projects { + id + } + } + } + QUERY + end + + let(:namespace) { create(:namespace) } + let(:projects) { create_list(:namespace_project, 2, namespace: namespace) } + let(:role) { create(:namespace_role, namespace: namespace) } + let(:input) do + { + roleId: role.to_global_id.to_s, + projectIds: [projects.first.to_global_id.to_s], + } + end + let(:variables) { { input: input } } + let(:current_user) { create(:user) } + + context 'when user has permission' do + before do + create(:namespace_member, namespace: namespace, user: current_user) + stub_all_abilities(NamespacePolicy) + stub_allowed_ability(NamespacePolicy, :assign_role_projects, user: current_user, subject: namespace) + stub_allowed_ability(NamespaceProjectPolicy, :read_namespace_project, user: current_user, subject: projects.first) + create(:namespace_role_project_assignment, role: role, project: projects.last) + end + + it 'assigns the given projects to the role' do + mutate! + + project_ids = graphql_data_at(:namespace_roles_assign_projects, :projects, :id) + expect(project_ids).to be_present + expect(project_ids).to be_a(Array) + + assigned_projects = project_ids.map { |id| SagittariusSchema.object_from_id(id) } + + expect(assigned_projects).to eq([projects.first]) + + is_expected.to create_audit_event( + :namespace_role_projects_updated, + author_id: current_user.id, + entity_id: role.id, + entity_type: 'NamespaceRole', + details: { + 'new_projects' => [{ 'id' => projects.first.id, 'name' => projects.first.name }], + 'old_projects' => [{ 'id' => projects.last.id, 'name' => projects.last.name }], + }, + target_id: namespace.id, + target_type: 'Namespace' + ) + end + end + + context 'when user does not have permission' do + it 'returns an error' do + mutate! + + expect(graphql_data_at(:namespace_roles_assign_projects, :projects)).to be_nil + expect( + graphql_data_at(:namespace_roles_assign_projects, :errors) + ).to include({ 'message' => 'missing_permission' }) + end + end +end diff --git a/spec/services/namespace_roles/assign_projects_service_spec.rb b/spec/services/namespace_roles/assign_projects_service_spec.rb new file mode 100644 index 00000000..98f9448d --- /dev/null +++ b/spec/services/namespace_roles/assign_projects_service_spec.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe NamespaceRoles::AssignProjectsService do + subject(:service_response) { described_class.new(current_user, role, projects).execute } + + let(:current_user) { create(:user) } + let(:role) { create(:namespace_role) } + let(:projects) { [] } + + context 'when user is nil' do + let(:current_user) { nil } + + it { is_expected.not_to be_success } + it { expect(service_response.payload).to eq(:missing_permission) } + it { expect { service_response }.not_to change { NamespaceRoleProjectAssignment.count } } + + it do + expect { service_response }.not_to create_audit_event(:namespace_role_projects_updated) + end + end + + context 'when user does not have permission' do + it { is_expected.not_to be_success } + it { expect(service_response.payload).to eq(:missing_permission) } + it { expect { service_response }.not_to change { NamespaceRoleProjectAssignment.count } } + + it do + expect { service_response }.not_to create_audit_event(:namespace_role_projects_updated) + end + end + + context 'when user has permission' do + context 'when adding a project' do + let(:projects) { [create(:namespace_project, namespace: role.namespace)] } + + before do + stub_allowed_ability(NamespacePolicy, :assign_role_projects, user: current_user, subject: role.namespace) + end + + it { is_expected.to be_success } + it { expect(service_response.payload).to eq(projects) } + it { expect { service_response }.to change { NamespaceRoleProjectAssignment.count }.by(1) } + + it do + expect { service_response }.to create_audit_event( + :namespace_role_projects_updated, + author_id: current_user.id, + entity_id: role.id, + entity_type: 'NamespaceRole', + details: { + 'old_projects' => [], + 'new_projects' => [{ 'id' => projects.first.id, 'name' => projects.first.name }], + }, + target_id: role.namespace.id, + target_type: 'Namespace' + ) + end + end + + context 'when removing a project' do + let(:projects) { [] } + let!(:role_assignment) { create(:namespace_role_project_assignment, role: role) } + + before do + stub_allowed_ability(NamespacePolicy, :assign_role_projects, user: current_user, subject: role.namespace) + end + + it { is_expected.to be_success } + it { expect(service_response.payload).to be_empty } + it { expect { service_response }.to change { NamespaceRoleProjectAssignment.count }.by(-1) } + + it do + expect { service_response }.to create_audit_event( + :namespace_role_projects_updated, + author_id: current_user.id, + entity_id: role.id, + entity_type: 'NamespaceRole', + details: { + 'old_projects' => [{ 'id' => role_assignment.project.id, 'name' => role_assignment.project.name }], + 'new_projects' => [], + }, + target_id: role.namespace.id, + target_type: 'Namespace' + ) + end + end + + context 'when adding and removing a project' do + let(:projects) { [create(:namespace_project, namespace: role.namespace)] } + let!(:role_assignment) { create(:namespace_role_project_assignment, role: role) } + + before do + stub_allowed_ability(NamespacePolicy, :assign_role_projects, user: current_user, subject: role.namespace) + end + + it { is_expected.to be_success } + it { expect(service_response.payload).to eq(projects) } + it { expect { service_response }.not_to change { NamespaceRoleProjectAssignment.count } } + + it do + expect { service_response }.to create_audit_event( + :namespace_role_projects_updated, + author_id: current_user.id, + entity_id: role.id, + entity_type: 'NamespaceRole', + details: { + 'old_projects' => [{ 'id' => role_assignment.project.id, 'name' => role_assignment.project.name }], + 'new_projects' => [{ 'id' => projects.first.id, 'name' => projects.first.name }], + }, + target_id: role.namespace.id, + target_type: 'Namespace' + ) + end + end + end +end diff --git a/spec/support/helpers/stub_ability.rb b/spec/support/helpers/stub_ability.rb index 11abdc0a..5608aa0c 100644 --- a/spec/support/helpers/stub_ability.rb +++ b/spec/support/helpers/stub_ability.rb @@ -13,6 +13,12 @@ def stub_allowed_ability(policy_class, ability, user: nil, subject: nil) .and_return(true) # rubocop:enable RSpec/AnyInstance end + + def stub_all_abilities(policy_class) + # rubocop:disable RSpec/AnyInstance -- policy instances are per user and subject + allow_any_instance_of(policy_class).to receive(:user_has_ability?).and_return(false) + # rubocop:enable RSpec/AnyInstance + end end RSpec.configure do |config|