Class: HrLite::Access

Inherits:
Object
  • Object
show all
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

Class Method Summary collapse

Instance Method Summary collapse

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, permission_map)
  @user = user
  @map = permission_map
end

Instance Attribute Details

#userObject (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

Returns:

  • (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.

Returns:

  • (Boolean)


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_idsObject

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