Class: HrLite::Access
- Inherits:
-
Object
- Object
- HrLite::Access
- Defined in:
- app/services/hr_lite/access.rb
Overview
The one place that answers "may this person do this, and to whose rows".
Two questions, deliberately separated:
can?(user, "leave.approve") — may they at all, at any scope
scope_for(user, "leave.approve") — :self, :team, :all or nil
reaches?(user, "leave.approve", other) — may they, for THIS person
A controller that only asks the first question is the bug this class exists to prevent: before roles, any admin could approve anybody's leave because nothing ever asked whose leave it was.
Instance Attribute Summary collapse
-
#user ⇒ Object
readonly
Returns the value of attribute user.
Class Method Summary collapse
-
.for(user) ⇒ Object
Resolution touches three tables and every screen asks several times per request, so it is memoized per user for the life of the request.
-
.resolve(user) ⇒ Object
The strongest scope held for each key across all of the user's roles.
Instance Method Summary collapse
- #can?(key, scope: :self) ⇒ Boolean
-
#initialize(user, permission_map) ⇒ Access
constructor
A new instance of Access.
-
#reaches?(key, subject_user) ⇒ Boolean
Whether this permission reaches a particular person's rows.
-
#report_ids ⇒ Object
Everyone below this person in the reporting chain.
- #scope_for(key) ⇒ Object
-
#scope_relation(relation, key, column: :user_id) ⇒ Object
Narrows a relation to the rows this permission reaches.
-
#visible_user_ids(key) ⇒ Object
User ids this person's
teamscope covers, for scoping a whole relation rather than checking one row at a time.
Constructor Details
#initialize(user, permission_map) ⇒ Access
Returns a new instance of Access.
39 40 41 42 |
# File 'app/services/hr_lite/access.rb', line 39 def initialize(user, ) @user = user @map = end |
Instance Attribute Details
#user ⇒ Object (readonly)
Returns the value of attribute user.
44 45 46 |
# File 'app/services/hr_lite/access.rb', line 44 def user @user end |
Class Method Details
.for(user) ⇒ Object
Resolution touches three tables and every screen asks several times per request, so it is memoized per user for the life of the request.
16 17 18 19 20 21 22 23 |
# File 'app/services/hr_lite/access.rb', line 16 def self.for(user) # An unsaved user holds nothing: roles are rows keyed by user id, and # there is no id yet to key them by. return new(nil, {}) if user.nil? || user.id.nil? Current.access_cache ||= {} Current.access_cache[user.id] ||= new(user, resolve(user)) end |
.resolve(user) ⇒ Object
The strongest scope held for each key across all of the user's roles. Two roles granting the same key keep the WIDER of the two — roles add up, they do not narrow each other.
28 29 30 31 32 33 34 35 36 37 |
# File 'app/services/hr_lite/access.rb', line 28 def self.resolve(user) grants = RoleGrant.joins(role: :role_assignments) .where(hr_lite_role_assignments: { user_id: user.id }) .pluck(:permission_key, :scope) grants.each_with_object({}) do |(key, scope), map| held = map[key] map[key] = scope if held.nil? || Permissions.scope_covers?(scope, held) end end |
Instance Method Details
#can?(key, scope: :self) ⇒ Boolean
50 51 52 53 |
# File 'app/services/hr_lite/access.rb', line 50 def can?(key, scope: :self) held = scope_for(key) held.present? && Permissions.scope_covers?(held, scope) end |
#reaches?(key, subject_user) ⇒ Boolean
Whether this permission reaches a particular person's rows. self
reaches only the holder; team reaches their direct and indirect
reports; all reaches everyone.
58 59 60 61 62 63 64 65 66 67 |
# File 'app/services/hr_lite/access.rb', line 58 def reaches?(key, subject_user) return false if user.nil? || subject_user.nil? case scope_for(key) when :all then true when :team then subject_user.id == user.id || report_ids.include?(subject_user.id) when :self then subject_user.id == user.id else false end end |
#report_ids ⇒ Object
Everyone below this person in the reporting chain. Walked breadth-first with a seen set — EmployeeProfile validates the chain is acyclic, but a cycle written directly to the database must not spin here forever.
92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 |
# File 'app/services/hr_lite/access.rb', line 92 def report_ids @report_ids ||= begin by_manager = EmployeeProfile.where.not(manager_id: nil).pluck(:manager_id, :user_id) .group_by(&:first) .transform_values { |pairs| pairs.map(&:last) } found = [] seen = [ user.id ].to_set queue = by_manager[user.id]&.dup || [] while (id = queue.shift) next unless seen.add?(id) found << id queue.concat(by_manager[id] || []) end found end end |
#scope_for(key) ⇒ Object
46 47 48 |
# File 'app/services/hr_lite/access.rb', line 46 def scope_for(key) @map[Permissions.validate!(key)]&.to_sym end |
#scope_relation(relation, key, column: :user_id) ⇒ Object
Narrows a relation to the rows this permission reaches. The column is named because not every table calls it user_id.
84 85 86 87 |
# File 'app/services/hr_lite/access.rb', line 84 def scope_relation(relation, key, column: :user_id) ids = visible_user_ids(key) ids.nil? ? relation : relation.where(column => ids) end |
#visible_user_ids(key) ⇒ Object
User ids this person's team scope covers, for scoping a whole
relation rather than checking one row at a time. all returns nil,
meaning "do not filter" — a caller that treats nil as an empty list
would show leadership nothing, so the callers use scope_relation.
73 74 75 76 77 78 79 80 |
# File 'app/services/hr_lite/access.rb', line 73 def visible_user_ids(key) case scope_for(key) when :all then nil when :team then [ user.id, *report_ids ] when :self then [ user.id ] else [] end end |