Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow role permissions to be restricted to specific projects #213

Merged
merged 1 commit into from
Jul 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions app/graphql/mutations/namespace_roles/assign_projects.rb
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions app/graphql/types/mutation_type.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions app/graphql/types/namespace_role_type.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions app/models/audit_event.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions app/models/namespace_project.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
11 changes: 11 additions & 0 deletions app/models/namespace_role.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
1 change: 1 addition & 0 deletions app/models/namespace_role_ability.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 6 additions & 0 deletions app/models/namespace_role_project_assignment.rb
Original file line number Diff line number Diff line change
@@ -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
9 changes: 5 additions & 4 deletions app/policies/concerns/customizable_permission.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions app/policies/namespace_policy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
61 changes: 61 additions & 0 deletions app/services/namespace_roles/assign_projects_service.rb
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions db/schema_migrations/20240622182317
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
6e5cf40f7ab5a1e704fe918addc71ae146439d096a1183734b725b986ea5fdea
32 changes: 32 additions & 0 deletions db/structure.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);

Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;

Expand Down
1 change: 1 addition & 0 deletions docs/content/graphql/enum/namespaceroleability.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
21 changes: 21 additions & 0 deletions docs/content/graphql/mutation/namespacerolesassignprojects.md
Original file line number Diff line number Diff line change
@@ -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 |
1 change: 1 addition & 0 deletions docs/content/graphql/object/namespacerole.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
8 changes: 8 additions & 0 deletions spec/factories/namespace_role_project_assignments.rb
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions spec/graphql/types/namespace_role_type_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
namespace
name
abilities
assignedProjects
createdAt
updatedAt
]
Expand Down
8 changes: 8 additions & 0 deletions spec/models/namespace_project_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions spec/models/namespace_role_project_assignment_spec.rb
Original file line number Diff line number Diff line change
@@ -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
8 changes: 8 additions & 0 deletions spec/models/namespace_role_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading