Skip to content

Commit 23a0505

Browse files
committed
Implement customizable permissions for roles
1 parent 9aecee0 commit 23a0505

21 files changed

+230
-2
lines changed

app/models/team_member.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,8 @@ class TeamMember < ApplicationRecord
44
belongs_to :team, inverse_of: :team_members
55
belongs_to :user, inverse_of: :team_memberships
66

7+
has_many :member_roles, class_name: 'TeamMemberRole', inverse_of: :member
8+
has_many :roles, class_name: 'TeamRole', through: :member_roles, inverse_of: :members
9+
710
validates :team, uniqueness: { scope: :user_id }
811
end

app/models/team_member_role.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# frozen_string_literal: true
2+
3+
class TeamMemberRole < ApplicationRecord
4+
belongs_to :role, class_name: 'TeamRole', inverse_of: :member_roles
5+
belongs_to :member, class_name: 'TeamMember', inverse_of: :member_roles
6+
end

app/models/team_role.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@
33
class TeamRole < ApplicationRecord
44
belongs_to :team, inverse_of: :roles
55

6+
has_many :abilities, class_name: 'TeamRoleAbility', inverse_of: :team_role
7+
has_many :member_roles, class_name: 'TeamMemberRole', inverse_of: :role
8+
has_many :members, class_name: 'TeamMember', through: :member_roles, inverse_of: :roles
9+
610
validates :name, presence: true,
711
length: { minimum: 3, maximum: 50 },
812
allow_blank: false,

app/models/team_role_ability.rb

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# frozen_string_literal: true
2+
3+
class TeamRoleAbility < ApplicationRecord
4+
ABILITIES = {
5+
create_team_role: 1,
6+
read_team_role: 2,
7+
}.with_indifferent_access
8+
9+
enum :ability, ABILITIES, prefix: :can
10+
11+
belongs_to :team_role, inverse_of: :abilities
12+
13+
validates :ability, presence: true,
14+
inclusion: {
15+
in: ABILITIES.keys.map(&:to_s),
16+
},
17+
uniqueness: { scope: :team_role_id }
18+
end
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# frozen_string_literal: true
2+
3+
module CustomizablePermission
4+
extend ActiveSupport::Concern
5+
6+
class_methods do
7+
attr_reader :team_resolver_block
8+
9+
def team_resolver(&block)
10+
@team_resolver_block = block
11+
end
12+
13+
def customizable_permission(ability)
14+
condition(ability) { user_has_ability?(ability, @user, @subject) }
15+
16+
rule { send ability }.enable ability
17+
end
18+
end
19+
20+
included do
21+
def team(subject)
22+
@team ||= self.class.team_resolver_block.call(subject)
23+
end
24+
25+
def team_member(user, subject)
26+
@team_member ||= team(subject).team_members.find_by(user: user)
27+
end
28+
29+
def user_has_ability?(ability, user, subject)
30+
return false if team_member(user, subject).nil?
31+
32+
team_member(user, subject).roles.joins(:abilities).exists?(team_role_abilities: { ability: ability })
33+
end
34+
end
35+
end

app/policies/team_policy.rb

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
# frozen_string_literal: true
22

33
class TeamPolicy < BasePolicy
4+
include CustomizablePermission
5+
46
condition(:is_member) { @subject.member?(@user) }
57

68
rule { is_member }.policy do
79
enable :read_team
810
enable :read_team_member
9-
enable :create_team_role
10-
enable :read_team_role
1111
end
12+
13+
team_resolver { |team| team }
14+
15+
customizable_permission :read_team_role
16+
customizable_permission :create_team_role
1217
end
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# frozen_string_literal: true
2+
3+
class CreateTeamRoleAbilities < Sagittarius::Database::Migration[1.0]
4+
def change
5+
create_table :team_role_abilities do |t|
6+
t.references :team_role, null: false, foreign_key: true
7+
t.integer :ability, null: false
8+
9+
t.index %i[team_role_id ability], unique: true
10+
11+
t.timestamps_with_timezone
12+
end
13+
end
14+
end
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# frozen_string_literal: true
2+
3+
class CreateTeamMemberRoles < Sagittarius::Database::Migration[1.0]
4+
def change
5+
create_table :team_member_roles do |t|
6+
t.references :role, null: false, foreign_key: { to_table: :team_roles }
7+
t.references :member, null: false, foreign_key: { to_table: :team_members }
8+
9+
t.timestamps_with_timezone
10+
end
11+
end
12+
end

db/schema_migrations/20240119203239

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
e00734fdb95a7b119f0d019b4bb0b6c00ca031e6455aba7692b44925010c410e

db/schema_migrations/20240119210312

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
703a10ef28c2895862250b9b15d71c59f72a53e5a560c90fd9b4ba18e21de661

db/structure.sql

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,23 @@ CREATE TABLE schema_migrations (
4949
version character varying NOT NULL
5050
);
5151

52+
CREATE TABLE team_member_roles (
53+
id bigint NOT NULL,
54+
role_id bigint NOT NULL,
55+
member_id bigint NOT NULL,
56+
created_at timestamp with time zone NOT NULL,
57+
updated_at timestamp with time zone NOT NULL
58+
);
59+
60+
CREATE SEQUENCE team_member_roles_id_seq
61+
START WITH 1
62+
INCREMENT BY 1
63+
NO MINVALUE
64+
NO MAXVALUE
65+
CACHE 1;
66+
67+
ALTER SEQUENCE team_member_roles_id_seq OWNED BY team_member_roles.id;
68+
5269
CREATE TABLE team_members (
5370
id bigint NOT NULL,
5471
team_id bigint NOT NULL,
@@ -66,6 +83,23 @@ CREATE SEQUENCE team_members_id_seq
6683

6784
ALTER SEQUENCE team_members_id_seq OWNED BY team_members.id;
6885

86+
CREATE TABLE team_role_abilities (
87+
id bigint NOT NULL,
88+
team_role_id bigint NOT NULL,
89+
ability integer NOT NULL,
90+
created_at timestamp with time zone NOT NULL,
91+
updated_at timestamp with time zone NOT NULL
92+
);
93+
94+
CREATE SEQUENCE team_role_abilities_id_seq
95+
START WITH 1
96+
INCREMENT BY 1
97+
NO MINVALUE
98+
NO MAXVALUE
99+
CACHE 1;
100+
101+
ALTER SEQUENCE team_role_abilities_id_seq OWNED BY team_role_abilities.id;
102+
69103
CREATE TABLE team_roles (
70104
id bigint NOT NULL,
71105
team_id bigint NOT NULL,
@@ -147,8 +181,12 @@ ALTER TABLE ONLY application_settings ALTER COLUMN id SET DEFAULT nextval('appli
147181

148182
ALTER TABLE ONLY audit_events ALTER COLUMN id SET DEFAULT nextval('audit_events_id_seq'::regclass);
149183

184+
ALTER TABLE ONLY team_member_roles ALTER COLUMN id SET DEFAULT nextval('team_member_roles_id_seq'::regclass);
185+
150186
ALTER TABLE ONLY team_members ALTER COLUMN id SET DEFAULT nextval('team_members_id_seq'::regclass);
151187

188+
ALTER TABLE ONLY team_role_abilities ALTER COLUMN id SET DEFAULT nextval('team_role_abilities_id_seq'::regclass);
189+
152190
ALTER TABLE ONLY team_roles ALTER COLUMN id SET DEFAULT nextval('team_roles_id_seq'::regclass);
153191

154192
ALTER TABLE ONLY teams ALTER COLUMN id SET DEFAULT nextval('teams_id_seq'::regclass);
@@ -169,9 +207,15 @@ ALTER TABLE ONLY audit_events
169207
ALTER TABLE ONLY schema_migrations
170208
ADD CONSTRAINT schema_migrations_pkey PRIMARY KEY (version);
171209

210+
ALTER TABLE ONLY team_member_roles
211+
ADD CONSTRAINT team_member_roles_pkey PRIMARY KEY (id);
212+
172213
ALTER TABLE ONLY team_members
173214
ADD CONSTRAINT team_members_pkey PRIMARY KEY (id);
174215

216+
ALTER TABLE ONLY team_role_abilities
217+
ADD CONSTRAINT team_role_abilities_pkey PRIMARY KEY (id);
218+
175219
ALTER TABLE ONLY team_roles
176220
ADD CONSTRAINT team_roles_pkey PRIMARY KEY (id);
177221

@@ -188,12 +232,20 @@ CREATE UNIQUE INDEX index_application_settings_on_setting ON application_setting
188232

189233
CREATE INDEX index_audit_events_on_author_id ON audit_events USING btree (author_id);
190234

235+
CREATE INDEX index_team_member_roles_on_member_id ON team_member_roles USING btree (member_id);
236+
237+
CREATE INDEX index_team_member_roles_on_role_id ON team_member_roles USING btree (role_id);
238+
191239
CREATE INDEX index_team_members_on_team_id ON team_members USING btree (team_id);
192240

193241
CREATE UNIQUE INDEX index_team_members_on_team_id_and_user_id ON team_members USING btree (team_id, user_id);
194242

195243
CREATE INDEX index_team_members_on_user_id ON team_members USING btree (user_id);
196244

245+
CREATE INDEX index_team_role_abilities_on_team_role_id ON team_role_abilities USING btree (team_role_id);
246+
247+
CREATE UNIQUE INDEX index_team_role_abilities_on_team_role_id_and_ability ON team_role_abilities USING btree (team_role_id, ability);
248+
197249
CREATE INDEX index_team_roles_on_team_id ON team_roles USING btree (team_id);
198250

199251
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(
211263
ALTER TABLE ONLY team_members
212264
ADD CONSTRAINT fk_rails_194b5b076d FOREIGN KEY (team_id) REFERENCES teams(id);
213265

266+
ALTER TABLE ONLY team_member_roles
267+
ADD CONSTRAINT fk_rails_2ba25f58d9 FOREIGN KEY (role_id) REFERENCES team_roles(id);
268+
269+
ALTER TABLE ONLY team_member_roles
270+
ADD CONSTRAINT fk_rails_5965594cb8 FOREIGN KEY (member_id) REFERENCES team_members(id);
271+
272+
ALTER TABLE ONLY team_role_abilities
273+
ADD CONSTRAINT fk_rails_88eb4b9f69 FOREIGN KEY (team_role_id) REFERENCES team_roles(id);
274+
214275
ALTER TABLE ONLY team_members
215276
ADD CONSTRAINT fk_rails_9ec2d5e75e FOREIGN KEY (user_id) REFERENCES users(id);
216277

spec/factories/team_member_roles.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# frozen_string_literal: true
2+
3+
FactoryBot.define do
4+
factory :team_member_role do
5+
role factory: :team_role
6+
member factory: :team_member
7+
end
8+
end

spec/factories/team_role_abilities.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# frozen_string_literal: true
2+
3+
FactoryBot.define do
4+
factory :team_role_ability do
5+
team_role
6+
ability { nil }
7+
end
8+
end

spec/models/team_member_role_spec.rb

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# frozen_string_literal: true
2+
3+
require 'rails_helper'
4+
5+
RSpec.describe TeamMemberRole do
6+
subject { create(:team_member_role) }
7+
8+
describe 'associations' do
9+
it { is_expected.to belong_to(:role).required.inverse_of(:member_roles) }
10+
it { is_expected.to belong_to(:member).required.inverse_of(:member_roles) }
11+
end
12+
end

spec/models/team_member_spec.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
describe 'associations' do
99
it { is_expected.to belong_to(:team).required }
1010
it { is_expected.to belong_to(:user).required }
11+
it { is_expected.to have_many(:member_roles).class_name('TeamMemberRole').inverse_of(:member) }
12+
it { is_expected.to have_many(:roles).class_name('TeamRole').through(:member_roles).inverse_of(:members) }
1113
end
1214

1315
describe 'validations' do

spec/models/team_role_ability_spec.rb

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# frozen_string_literal: true
2+
3+
require 'rails_helper'
4+
5+
RSpec.describe TeamRoleAbility do
6+
subject { create(:team_role_ability, ability: :create_team_role) }
7+
8+
describe 'associations' do
9+
it { is_expected.to belong_to(:team_role).required }
10+
end
11+
12+
describe 'validations' do
13+
it { is_expected.to validate_uniqueness_of(:ability).ignoring_case_sensitivity.scoped_to(:team_role_id) }
14+
it { is_expected.to allow_values(*described_class::ABILITIES.keys).for(:ability) }
15+
end
16+
end

spec/models/team_role_spec.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77

88
describe 'associations' do
99
it { is_expected.to belong_to(:team).required }
10+
it { is_expected.to have_many(:member_roles).class_name('TeamMemberRole').inverse_of(:role) }
11+
it { is_expected.to have_many(:members).class_name('TeamMember').through(:member_roles).inverse_of(:roles) }
1012
end
1113

1214
describe 'validations' do

spec/models/team_spec.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
describe 'associations' do
99
it { is_expected.to have_many(:team_members).inverse_of(:team) }
10+
it { is_expected.to have_many(:roles).inverse_of(:team) }
1011
it { is_expected.to have_many(:users).through(:team_members).inverse_of(:teams) }
1112
end
1213

spec/requests/graphql/mutation/team_roles/create_mutation_spec.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@
4040
context 'when user is a member of the team' do
4141
before do
4242
create(:team_member, team: team, user: current_user)
43+
stub_allowed_ability(TeamPolicy, :create_team_role, user: current_user, subject: team)
44+
stub_allowed_ability(TeamPolicy, :read_team_role, user: current_user, subject: team)
4345
end
4446

4547
it 'creates team role' do

spec/services/team_roles/create_service_spec.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838

3939
before do
4040
create(:team_member, team: team, user: current_user)
41+
stub_allowed_ability(TeamPolicy, :create_team_role, user: current_user, subject: team)
4142
end
4243

4344
it { is_expected.to be_success }

spec/support/helpers/stub_ability.rb

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# frozen_string_literal: true
2+
3+
module StubAbility
4+
def stub_allowed_ability(policy_class, ability, user: nil, subject: nil)
5+
# rubocop:disable RSpec/AnyInstance -- policy instances are per user and subject
6+
allow_any_instance_of(policy_class)
7+
.to receive(:user_has_ability?)
8+
.with(ability, user, subject)
9+
.and_return(true)
10+
# rubocop:enable RSpec/AnyInstance
11+
end
12+
end
13+
14+
RSpec.configure do |config|
15+
config.include StubAbility
16+
end

0 commit comments

Comments
 (0)