Skip to content

Commit

Permalink
Implement customizable permissions for roles
Browse files Browse the repository at this point in the history
  • Loading branch information
Taucher2003 committed Jan 19, 2024
1 parent 9aecee0 commit 23a0505
Show file tree
Hide file tree
Showing 21 changed files with 230 additions and 2 deletions.
3 changes: 3 additions & 0 deletions app/models/team_member.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 6 additions & 0 deletions app/models/team_member_role.rb
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions app/models/team_role.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
18 changes: 18 additions & 0 deletions app/models/team_role_ability.rb
Original file line number Diff line number Diff line change
@@ -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
35 changes: 35 additions & 0 deletions app/policies/concerns/customizable_permission.rb
Original file line number Diff line number Diff line change
@@ -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
9 changes: 7 additions & 2 deletions app/policies/team_policy.rb
Original file line number Diff line number Diff line change
@@ -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
14 changes: 14 additions & 0 deletions db/migrate/20240119203239_create_team_role_abilities.rb
Original file line number Diff line number Diff line change
@@ -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
12 changes: 12 additions & 0 deletions db/migrate/20240119210312_create_team_member_roles.rb
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions db/schema_migrations/20240119203239
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
e00734fdb95a7b119f0d019b4bb0b6c00ca031e6455aba7692b44925010c410e
1 change: 1 addition & 0 deletions db/schema_migrations/20240119210312
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
703a10ef28c2895862250b9b15d71c59f72a53e5a560c90fd9b4ba18e21de661
61 changes: 61 additions & 0 deletions db/structure.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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);
Expand All @@ -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);

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

Expand Down
8 changes: 8 additions & 0 deletions spec/factories/team_member_roles.rb
Original file line number Diff line number Diff line change
@@ -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
8 changes: 8 additions & 0 deletions spec/factories/team_role_abilities.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# frozen_string_literal: true

FactoryBot.define do
factory :team_role_ability do
team_role
ability { nil }
end
end
12 changes: 12 additions & 0 deletions spec/models/team_member_role_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 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
2 changes: 2 additions & 0 deletions spec/models/team_member_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions spec/models/team_role_ability_spec.rb
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions spec/models/team_role_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions spec/models/team_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions spec/services/team_roles/create_service_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
16 changes: 16 additions & 0 deletions spec/support/helpers/stub_ability.rb
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit 23a0505

Please sign in to comment.