From 23a0505688b034911af0b0b6f500bcb23574b67c Mon Sep 17 00:00:00 2001 From: Niklas van Schrick Date: Sat, 20 Jan 2024 00:48:07 +0100 Subject: [PATCH] Implement customizable permissions for roles --- app/models/team_member.rb | 3 + app/models/team_member_role.rb | 6 ++ app/models/team_role.rb | 4 ++ app/models/team_role_ability.rb | 18 ++++++ .../concerns/customizable_permission.rb | 35 +++++++++++ app/policies/team_policy.rb | 9 ++- ...240119203239_create_team_role_abilities.rb | 14 +++++ ...20240119210312_create_team_member_roles.rb | 12 ++++ db/schema_migrations/20240119203239 | 1 + db/schema_migrations/20240119210312 | 1 + db/structure.sql | 61 +++++++++++++++++++ spec/factories/team_member_roles.rb | 8 +++ spec/factories/team_role_abilities.rb | 8 +++ spec/models/team_member_role_spec.rb | 12 ++++ spec/models/team_member_spec.rb | 2 + spec/models/team_role_ability_spec.rb | 16 +++++ spec/models/team_role_spec.rb | 2 + spec/models/team_spec.rb | 1 + .../team_roles/create_mutation_spec.rb | 2 + .../team_roles/create_service_spec.rb | 1 + spec/support/helpers/stub_ability.rb | 16 +++++ 21 files changed, 230 insertions(+), 2 deletions(-) create mode 100644 app/models/team_member_role.rb create mode 100644 app/models/team_role_ability.rb create mode 100644 app/policies/concerns/customizable_permission.rb create mode 100644 db/migrate/20240119203239_create_team_role_abilities.rb create mode 100644 db/migrate/20240119210312_create_team_member_roles.rb create mode 100644 db/schema_migrations/20240119203239 create mode 100644 db/schema_migrations/20240119210312 create mode 100644 spec/factories/team_member_roles.rb create mode 100644 spec/factories/team_role_abilities.rb create mode 100644 spec/models/team_member_role_spec.rb create mode 100644 spec/models/team_role_ability_spec.rb create mode 100644 spec/support/helpers/stub_ability.rb diff --git a/app/models/team_member.rb b/app/models/team_member.rb index 29b88051..00a235c0 100644 --- a/app/models/team_member.rb +++ b/app/models/team_member.rb @@ -4,5 +4,8 @@ class TeamMember < ApplicationRecord belongs_to :team, inverse_of: :team_members belongs_to :user, inverse_of: :team_memberships + has_many :member_roles, class_name: 'TeamMemberRole', inverse_of: :member + has_many :roles, class_name: 'TeamRole', through: :member_roles, inverse_of: :members + validates :team, uniqueness: { scope: :user_id } end diff --git a/app/models/team_member_role.rb b/app/models/team_member_role.rb new file mode 100644 index 00000000..01cfad81 --- /dev/null +++ b/app/models/team_member_role.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +class TeamMemberRole < ApplicationRecord + belongs_to :role, class_name: 'TeamRole', inverse_of: :member_roles + belongs_to :member, class_name: 'TeamMember', inverse_of: :member_roles +end diff --git a/app/models/team_role.rb b/app/models/team_role.rb index f9b87c97..f5f4a02d 100644 --- a/app/models/team_role.rb +++ b/app/models/team_role.rb @@ -3,6 +3,10 @@ class TeamRole < ApplicationRecord belongs_to :team, inverse_of: :roles + has_many :abilities, class_name: 'TeamRoleAbility', inverse_of: :team_role + has_many :member_roles, class_name: 'TeamMemberRole', inverse_of: :role + has_many :members, class_name: 'TeamMember', through: :member_roles, inverse_of: :roles + validates :name, presence: true, length: { minimum: 3, maximum: 50 }, allow_blank: false, diff --git a/app/models/team_role_ability.rb b/app/models/team_role_ability.rb new file mode 100644 index 00000000..b14e8ada --- /dev/null +++ b/app/models/team_role_ability.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class TeamRoleAbility < ApplicationRecord + ABILITIES = { + create_team_role: 1, + read_team_role: 2, + }.with_indifferent_access + + enum :ability, ABILITIES, prefix: :can + + belongs_to :team_role, inverse_of: :abilities + + validates :ability, presence: true, + inclusion: { + in: ABILITIES.keys.map(&:to_s), + }, + uniqueness: { scope: :team_role_id } +end diff --git a/app/policies/concerns/customizable_permission.rb b/app/policies/concerns/customizable_permission.rb new file mode 100644 index 00000000..f334e6b9 --- /dev/null +++ b/app/policies/concerns/customizable_permission.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module CustomizablePermission + extend ActiveSupport::Concern + + class_methods do + attr_reader :team_resolver_block + + def team_resolver(&block) + @team_resolver_block = block + end + + def customizable_permission(ability) + condition(ability) { user_has_ability?(ability, @user, @subject) } + + rule { send ability }.enable ability + end + end + + included do + def team(subject) + @team ||= self.class.team_resolver_block.call(subject) + end + + def team_member(user, subject) + @team_member ||= team(subject).team_members.find_by(user: user) + end + + def user_has_ability?(ability, user, subject) + return false if team_member(user, subject).nil? + + team_member(user, subject).roles.joins(:abilities).exists?(team_role_abilities: { ability: ability }) + end + end +end diff --git a/app/policies/team_policy.rb b/app/policies/team_policy.rb index 9d159179..7f860b76 100644 --- a/app/policies/team_policy.rb +++ b/app/policies/team_policy.rb @@ -1,12 +1,17 @@ # frozen_string_literal: true class TeamPolicy < BasePolicy + include CustomizablePermission + condition(:is_member) { @subject.member?(@user) } rule { is_member }.policy do enable :read_team enable :read_team_member - enable :create_team_role - enable :read_team_role end + + team_resolver { |team| team } + + customizable_permission :read_team_role + customizable_permission :create_team_role end diff --git a/db/migrate/20240119203239_create_team_role_abilities.rb b/db/migrate/20240119203239_create_team_role_abilities.rb new file mode 100644 index 00000000..069ad189 --- /dev/null +++ b/db/migrate/20240119203239_create_team_role_abilities.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class CreateTeamRoleAbilities < Sagittarius::Database::Migration[1.0] + def change + create_table :team_role_abilities do |t| + t.references :team_role, null: false, foreign_key: true + t.integer :ability, null: false + + t.index %i[team_role_id ability], unique: true + + t.timestamps_with_timezone + end + end +end diff --git a/db/migrate/20240119210312_create_team_member_roles.rb b/db/migrate/20240119210312_create_team_member_roles.rb new file mode 100644 index 00000000..64cb441a --- /dev/null +++ b/db/migrate/20240119210312_create_team_member_roles.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class CreateTeamMemberRoles < Sagittarius::Database::Migration[1.0] + def change + create_table :team_member_roles do |t| + t.references :role, null: false, foreign_key: { to_table: :team_roles } + t.references :member, null: false, foreign_key: { to_table: :team_members } + + t.timestamps_with_timezone + end + end +end diff --git a/db/schema_migrations/20240119203239 b/db/schema_migrations/20240119203239 new file mode 100644 index 00000000..0b592090 --- /dev/null +++ b/db/schema_migrations/20240119203239 @@ -0,0 +1 @@ +e00734fdb95a7b119f0d019b4bb0b6c00ca031e6455aba7692b44925010c410e \ No newline at end of file diff --git a/db/schema_migrations/20240119210312 b/db/schema_migrations/20240119210312 new file mode 100644 index 00000000..cd044612 --- /dev/null +++ b/db/schema_migrations/20240119210312 @@ -0,0 +1 @@ +703a10ef28c2895862250b9b15d71c59f72a53e5a560c90fd9b4ba18e21de661 \ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index ee7e8ec6..48c5c238 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -49,6 +49,23 @@ CREATE TABLE schema_migrations ( version character varying NOT NULL ); +CREATE TABLE team_member_roles ( + id bigint NOT NULL, + role_id bigint NOT NULL, + member_id bigint NOT NULL, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL +); + +CREATE SEQUENCE team_member_roles_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE team_member_roles_id_seq OWNED BY team_member_roles.id; + CREATE TABLE team_members ( id bigint NOT NULL, team_id bigint NOT NULL, @@ -66,6 +83,23 @@ CREATE SEQUENCE team_members_id_seq ALTER SEQUENCE team_members_id_seq OWNED BY team_members.id; +CREATE TABLE team_role_abilities ( + id bigint NOT NULL, + team_role_id bigint NOT NULL, + ability integer NOT NULL, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL +); + +CREATE SEQUENCE team_role_abilities_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE team_role_abilities_id_seq OWNED BY team_role_abilities.id; + CREATE TABLE team_roles ( id bigint NOT NULL, team_id bigint NOT NULL, @@ -147,8 +181,12 @@ ALTER TABLE ONLY application_settings ALTER COLUMN id SET DEFAULT nextval('appli ALTER TABLE ONLY audit_events ALTER COLUMN id SET DEFAULT nextval('audit_events_id_seq'::regclass); +ALTER TABLE ONLY team_member_roles ALTER COLUMN id SET DEFAULT nextval('team_member_roles_id_seq'::regclass); + ALTER TABLE ONLY team_members ALTER COLUMN id SET DEFAULT nextval('team_members_id_seq'::regclass); +ALTER TABLE ONLY team_role_abilities ALTER COLUMN id SET DEFAULT nextval('team_role_abilities_id_seq'::regclass); + ALTER TABLE ONLY team_roles ALTER COLUMN id SET DEFAULT nextval('team_roles_id_seq'::regclass); ALTER TABLE ONLY teams ALTER COLUMN id SET DEFAULT nextval('teams_id_seq'::regclass); @@ -169,9 +207,15 @@ ALTER TABLE ONLY audit_events ALTER TABLE ONLY schema_migrations ADD CONSTRAINT schema_migrations_pkey PRIMARY KEY (version); +ALTER TABLE ONLY team_member_roles + ADD CONSTRAINT team_member_roles_pkey PRIMARY KEY (id); + ALTER TABLE ONLY team_members ADD CONSTRAINT team_members_pkey PRIMARY KEY (id); +ALTER TABLE ONLY team_role_abilities + ADD CONSTRAINT team_role_abilities_pkey PRIMARY KEY (id); + ALTER TABLE ONLY team_roles ADD CONSTRAINT team_roles_pkey PRIMARY KEY (id); @@ -188,12 +232,20 @@ CREATE UNIQUE INDEX index_application_settings_on_setting ON application_setting CREATE INDEX index_audit_events_on_author_id ON audit_events USING btree (author_id); +CREATE INDEX index_team_member_roles_on_member_id ON team_member_roles USING btree (member_id); + +CREATE INDEX index_team_member_roles_on_role_id ON team_member_roles USING btree (role_id); + CREATE INDEX index_team_members_on_team_id ON team_members USING btree (team_id); CREATE UNIQUE INDEX index_team_members_on_team_id_and_user_id ON team_members USING btree (team_id, user_id); CREATE INDEX index_team_members_on_user_id ON team_members USING btree (user_id); +CREATE INDEX index_team_role_abilities_on_team_role_id ON team_role_abilities USING btree (team_role_id); + +CREATE UNIQUE INDEX index_team_role_abilities_on_team_role_id_and_ability ON team_role_abilities USING btree (team_role_id, ability); + CREATE INDEX index_team_roles_on_team_id ON team_roles USING btree (team_id); CREATE UNIQUE INDEX "index_team_roles_on_team_id_LOWER_name" ON team_roles USING btree (team_id, lower(name)); @@ -211,6 +263,15 @@ CREATE UNIQUE INDEX "index_users_on_LOWER_username" ON users USING btree (lower( ALTER TABLE ONLY team_members ADD CONSTRAINT fk_rails_194b5b076d FOREIGN KEY (team_id) REFERENCES teams(id); +ALTER TABLE ONLY team_member_roles + ADD CONSTRAINT fk_rails_2ba25f58d9 FOREIGN KEY (role_id) REFERENCES team_roles(id); + +ALTER TABLE ONLY team_member_roles + ADD CONSTRAINT fk_rails_5965594cb8 FOREIGN KEY (member_id) REFERENCES team_members(id); + +ALTER TABLE ONLY team_role_abilities + ADD CONSTRAINT fk_rails_88eb4b9f69 FOREIGN KEY (team_role_id) REFERENCES team_roles(id); + ALTER TABLE ONLY team_members ADD CONSTRAINT fk_rails_9ec2d5e75e FOREIGN KEY (user_id) REFERENCES users(id); diff --git a/spec/factories/team_member_roles.rb b/spec/factories/team_member_roles.rb new file mode 100644 index 00000000..598bb7bf --- /dev/null +++ b/spec/factories/team_member_roles.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :team_member_role do + role factory: :team_role + member factory: :team_member + end +end diff --git a/spec/factories/team_role_abilities.rb b/spec/factories/team_role_abilities.rb new file mode 100644 index 00000000..df277abc --- /dev/null +++ b/spec/factories/team_role_abilities.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :team_role_ability do + team_role + ability { nil } + end +end diff --git a/spec/models/team_member_role_spec.rb b/spec/models/team_member_role_spec.rb new file mode 100644 index 00000000..7098fe1f --- /dev/null +++ b/spec/models/team_member_role_spec.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe TeamMemberRole do + subject { create(:team_member_role) } + + describe 'associations' do + it { is_expected.to belong_to(:role).required.inverse_of(:member_roles) } + it { is_expected.to belong_to(:member).required.inverse_of(:member_roles) } + end +end diff --git a/spec/models/team_member_spec.rb b/spec/models/team_member_spec.rb index 3105663b..06b4baed 100644 --- a/spec/models/team_member_spec.rb +++ b/spec/models/team_member_spec.rb @@ -8,6 +8,8 @@ describe 'associations' do it { is_expected.to belong_to(:team).required } it { is_expected.to belong_to(:user).required } + it { is_expected.to have_many(:member_roles).class_name('TeamMemberRole').inverse_of(:member) } + it { is_expected.to have_many(:roles).class_name('TeamRole').through(:member_roles).inverse_of(:members) } end describe 'validations' do diff --git a/spec/models/team_role_ability_spec.rb b/spec/models/team_role_ability_spec.rb new file mode 100644 index 00000000..10ee85df --- /dev/null +++ b/spec/models/team_role_ability_spec.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe TeamRoleAbility do + subject { create(:team_role_ability, ability: :create_team_role) } + + describe 'associations' do + it { is_expected.to belong_to(:team_role).required } + end + + describe 'validations' do + it { is_expected.to validate_uniqueness_of(:ability).ignoring_case_sensitivity.scoped_to(:team_role_id) } + it { is_expected.to allow_values(*described_class::ABILITIES.keys).for(:ability) } + end +end diff --git a/spec/models/team_role_spec.rb b/spec/models/team_role_spec.rb index c83cba87..4d8a4b58 100644 --- a/spec/models/team_role_spec.rb +++ b/spec/models/team_role_spec.rb @@ -7,6 +7,8 @@ describe 'associations' do it { is_expected.to belong_to(:team).required } + it { is_expected.to have_many(:member_roles).class_name('TeamMemberRole').inverse_of(:role) } + it { is_expected.to have_many(:members).class_name('TeamMember').through(:member_roles).inverse_of(:roles) } end describe 'validations' do diff --git a/spec/models/team_spec.rb b/spec/models/team_spec.rb index eeb0e56e..f4842c16 100644 --- a/spec/models/team_spec.rb +++ b/spec/models/team_spec.rb @@ -7,6 +7,7 @@ describe 'associations' do it { is_expected.to have_many(:team_members).inverse_of(:team) } + it { is_expected.to have_many(:roles).inverse_of(:team) } it { is_expected.to have_many(:users).through(:team_members).inverse_of(:teams) } end diff --git a/spec/requests/graphql/mutation/team_roles/create_mutation_spec.rb b/spec/requests/graphql/mutation/team_roles/create_mutation_spec.rb index 12c4f7f6..a65016f1 100644 --- a/spec/requests/graphql/mutation/team_roles/create_mutation_spec.rb +++ b/spec/requests/graphql/mutation/team_roles/create_mutation_spec.rb @@ -40,6 +40,8 @@ context 'when user is a member of the team' do before do create(:team_member, team: team, user: current_user) + stub_allowed_ability(TeamPolicy, :create_team_role, user: current_user, subject: team) + stub_allowed_ability(TeamPolicy, :read_team_role, user: current_user, subject: team) end it 'creates team role' do diff --git a/spec/services/team_roles/create_service_spec.rb b/spec/services/team_roles/create_service_spec.rb index f27c894e..e945edd6 100644 --- a/spec/services/team_roles/create_service_spec.rb +++ b/spec/services/team_roles/create_service_spec.rb @@ -38,6 +38,7 @@ before do create(:team_member, team: team, user: current_user) + stub_allowed_ability(TeamPolicy, :create_team_role, user: current_user, subject: team) end it { is_expected.to be_success } diff --git a/spec/support/helpers/stub_ability.rb b/spec/support/helpers/stub_ability.rb new file mode 100644 index 00000000..f39fc15a --- /dev/null +++ b/spec/support/helpers/stub_ability.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module StubAbility + def stub_allowed_ability(policy_class, ability, user: nil, subject: nil) + # rubocop:disable RSpec/AnyInstance -- policy instances are per user and subject + allow_any_instance_of(policy_class) + .to receive(:user_has_ability?) + .with(ability, user, subject) + .and_return(true) + # rubocop:enable RSpec/AnyInstance + end +end + +RSpec.configure do |config| + config.include StubAbility +end