Skip to content

Commit

Permalink
Allow role permissions to be restricted to specific projects
Browse files Browse the repository at this point in the history
  • Loading branch information
Taucher2003 committed Jul 5, 2024
1 parent 0ab7471 commit 72c6be2
Show file tree
Hide file tree
Showing 27 changed files with 476 additions and 4 deletions.
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

0 comments on commit 72c6be2

Please sign in to comment.