hr_lite 0.5.3 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +51 -1
- data/README.md +67 -31
- data/app/controllers/hr_lite/admin/attendances_controller.rb +18 -2
- data/app/controllers/hr_lite/admin/base_controller.rb +16 -6
- data/app/controllers/hr_lite/admin/comp_off_requests_controller.rb +16 -4
- data/app/controllers/hr_lite/admin/leadership_controller.rb +11 -8
- data/app/controllers/hr_lite/admin/leave_requests_controller.rb +21 -5
- data/app/controllers/hr_lite/admin/regularization_requests_controller.rb +16 -4
- data/app/controllers/hr_lite/admin/role_assignments_controller.rb +63 -0
- data/app/controllers/hr_lite/admin/roles_controller.rb +73 -0
- data/app/controllers/hr_lite/admin/superadmin_controller.rb +15 -11
- data/app/controllers/hr_lite/application_controller.rb +39 -1
- data/app/helpers/hr_lite/application_helper.rb +5 -2
- data/app/models/hr_lite/role.rb +66 -0
- data/app/models/hr_lite/role_assignment.rb +18 -0
- data/app/models/hr_lite/role_grant.rb +15 -0
- data/app/services/hr_lite/access.rb +111 -0
- data/app/views/hr_lite/admin/roles/_form.html.erb +47 -0
- data/app/views/hr_lite/admin/roles/edit.html.erb +53 -0
- data/app/views/hr_lite/admin/roles/index.html.erb +49 -0
- data/app/views/hr_lite/admin/roles/new.html.erb +3 -0
- data/config/routes.rb +3 -0
- data/db/migrate/20260817183347_create_hr_lite_roles_and_grants.rb +49 -0
- data/db/migrate/20260817183629_seed_hr_lite_roles_from_email_tiers.rb +82 -0
- data/lib/generators/hr_lite/install/templates/initializer.rb +13 -7
- data/lib/hr_lite/configuration.rb +8 -1
- data/lib/hr_lite/current.rb +4 -0
- data/lib/hr_lite/permissions.rb +79 -0
- data/lib/hr_lite/role_seeds.rb +81 -0
- data/lib/hr_lite/version.rb +1 -1
- data/lib/hr_lite.rb +63 -10
- data/lib/tasks/hr_lite_tasks.rake +10 -2
- metadata +15 -1
|
@@ -10,10 +10,48 @@ module HrLite
|
|
|
10
10
|
before_action :hr_set_current_actor
|
|
11
11
|
around_action :hr_use_time_zone
|
|
12
12
|
|
|
13
|
-
helper_method :hr_current_user, :hr_admin?, :hr_leadership?, :hr_superadmin?, :hr_display_name
|
|
13
|
+
helper_method :hr_current_user, :hr_admin?, :hr_leadership?, :hr_superadmin?, :hr_display_name,
|
|
14
|
+
:hr_can?, :hr_reaches?, :hr_access
|
|
14
15
|
|
|
15
16
|
private
|
|
16
17
|
|
|
18
|
+
# --- authorization -----------------------------------------------------
|
|
19
|
+
|
|
20
|
+
def hr_access
|
|
21
|
+
HrLite::Access.for(hr_current_user)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# May the signed-in person do this at all, at `scope` or wider?
|
|
25
|
+
def hr_can?(key, scope: :self)
|
|
26
|
+
hr_access.can?(key, scope: scope)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# May they do it to THIS person's records? The question the module could
|
|
30
|
+
# not ask before roles, and whose absence let any admin approve anybody's
|
|
31
|
+
# leave.
|
|
32
|
+
def hr_reaches?(key, subject_user)
|
|
33
|
+
hr_access.reaches?(key, subject_user)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Gate a whole controller or action. `scope` is the weakest scope that can
|
|
37
|
+
# reach the screen at all — a manager reaching a team screen still has
|
|
38
|
+
# every ROW checked separately by `hr_require_reach!`.
|
|
39
|
+
def hr_require_permission!(key, scope: :self)
|
|
40
|
+
hr_access_denied unless hr_can?(key, scope: scope)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Gate one record. Deliberately a 404, not a 403: whether a particular
|
|
44
|
+
# person exists is not something a stranger gets to learn from us.
|
|
45
|
+
def hr_require_reach!(key, subject_user)
|
|
46
|
+
raise ActiveRecord::RecordNotFound unless hr_reaches?(key, subject_user)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Narrow a relation to the rows this permission reaches, so an index
|
|
50
|
+
# never renders a row the member action would refuse.
|
|
51
|
+
def hr_scope(relation, key, column: :user_id)
|
|
52
|
+
hr_access.scope_relation(relation, key, column: column)
|
|
53
|
+
end
|
|
54
|
+
|
|
17
55
|
def hr_authenticate!
|
|
18
56
|
send(HrLite.config.authenticate_method)
|
|
19
57
|
end
|
|
@@ -24,9 +24,12 @@ module HrLite
|
|
|
24
24
|
{ label: "Audit", path: :admin_audit_logs_path, match: [ "/admin/audit_logs" ] }
|
|
25
25
|
].freeze
|
|
26
26
|
|
|
27
|
-
# Money
|
|
27
|
+
# Money screens, plus role management: granting somebody payroll is the
|
|
28
|
+
# same act as holding it, so it sits with the pay rather than with the
|
|
29
|
+
# other governing screens.
|
|
28
30
|
SUPERADMIN_NAV_ITEMS = [
|
|
29
|
-
{ label: "Payroll", path: :admin_payroll_runs_path, match: [ "/admin/payroll_runs", "/admin/salary_slips" ] }
|
|
31
|
+
{ label: "Payroll", path: :admin_payroll_runs_path, match: [ "/admin/payroll_runs", "/admin/salary_slips" ] },
|
|
32
|
+
{ label: "Roles", path: :admin_roles_path, match: [ "/admin/roles" ] }
|
|
30
33
|
].freeze
|
|
31
34
|
|
|
32
35
|
# Only items whose routes exist yet (the nav grows with each phase).
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# A named bundle of grants. Roles are DATA — the seeded six are a starting
|
|
3
|
+
# point an install is expected to edit, not a fixed ladder in code.
|
|
4
|
+
class Role < ApplicationRecord
|
|
5
|
+
include Audited
|
|
6
|
+
|
|
7
|
+
# Seeded by name. `Employee` in particular is load-bearing: it is what a
|
|
8
|
+
# new hire is given so that self-service works on day one.
|
|
9
|
+
EMPLOYEE = "Employee".freeze
|
|
10
|
+
MANAGER = "Manager".freeze
|
|
11
|
+
HR = "HR".freeze
|
|
12
|
+
FINANCE = "Finance".freeze
|
|
13
|
+
LEADERSHIP = "Leadership".freeze
|
|
14
|
+
SUPER_ADMIN = "Super Admin".freeze
|
|
15
|
+
|
|
16
|
+
has_many :role_grants, dependent: :destroy
|
|
17
|
+
has_many :role_assignments, dependent: :destroy
|
|
18
|
+
|
|
19
|
+
validates :name, presence: true, uniqueness: { case_sensitive: false }
|
|
20
|
+
before_destroy :protect_system_role
|
|
21
|
+
validate :system_role_keeps_its_name, on: :update
|
|
22
|
+
|
|
23
|
+
scope :alphabetical, -> { order(:name) }
|
|
24
|
+
|
|
25
|
+
# Every key this role grants, mapped to its scope. The shape the access
|
|
26
|
+
# resolver merges.
|
|
27
|
+
def grant_map
|
|
28
|
+
role_grants.to_h { |grant| [ grant.permission_key, grant.scope ] }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Replaces the whole grant set in one transaction — the roles screen posts
|
|
32
|
+
# the complete picture, so a permission absent from the form is a
|
|
33
|
+
# permission being taken away, not one left alone.
|
|
34
|
+
def replace_grants!(grants)
|
|
35
|
+
transaction do
|
|
36
|
+
role_grants.destroy_all
|
|
37
|
+
grants.each do |key, scope|
|
|
38
|
+
next if scope.blank? || scope.to_s == "none"
|
|
39
|
+
|
|
40
|
+
role_grants.create!(permission_key: Permissions.validate!(key), scope: scope.to_s)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
reload
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def system? = self[:system]
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def protect_system_role
|
|
51
|
+
return unless system?
|
|
52
|
+
|
|
53
|
+
errors.add(:base, "#{name} is a built-in role and cannot be deleted")
|
|
54
|
+
throw :abort
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The name is the identifier the seed, the upgrade path and the specs all
|
|
58
|
+
# use. Permissions on a system role are free to change; what it is called
|
|
59
|
+
# is not.
|
|
60
|
+
def system_role_keeps_its_name
|
|
61
|
+
return unless system? && name_changed?
|
|
62
|
+
|
|
63
|
+
errors.add(:name, "cannot be changed on a built-in role")
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# Who holds which role. Assignments are audited because granting somebody
|
|
3
|
+
# the money tier is exactly the kind of change that has to be explainable
|
|
4
|
+
# afterwards.
|
|
5
|
+
class RoleAssignment < ApplicationRecord
|
|
6
|
+
include Audited
|
|
7
|
+
|
|
8
|
+
belongs_to :role
|
|
9
|
+
belongs_to :user, class_name: HrLite.config.user_class
|
|
10
|
+
belongs_to :granted_by, class_name: HrLite.config.user_class, optional: true
|
|
11
|
+
|
|
12
|
+
validates :user_id, uniqueness: { scope: :role_id }
|
|
13
|
+
|
|
14
|
+
scope :for_user, ->(user) { where(user_id: user.id) }
|
|
15
|
+
|
|
16
|
+
def hr_lite_audit_label = "#{HrLite.display_name(user)} — #{role.name}"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# One (role, permission, scope) triple. The permission key is validated
|
|
3
|
+
# against the declared registry, so a typo is a refused write rather than a
|
|
4
|
+
# grant that silently never matches anything.
|
|
5
|
+
class RoleGrant < ApplicationRecord
|
|
6
|
+
belongs_to :role
|
|
7
|
+
|
|
8
|
+
validates :permission_key, presence: true,
|
|
9
|
+
inclusion: { in: Permissions::KEYS, message: "is not a known permission" },
|
|
10
|
+
uniqueness: { scope: :role_id }
|
|
11
|
+
validates :scope, inclusion: { in: Permissions::SCOPES.map(&:to_s) }
|
|
12
|
+
|
|
13
|
+
def covers?(needed_scope) = Permissions.scope_covers?(scope, needed_scope)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# The one place that answers "may this person do this, and to whose rows".
|
|
3
|
+
#
|
|
4
|
+
# Two questions, deliberately separated:
|
|
5
|
+
#
|
|
6
|
+
# can?(user, "leave.approve") — may they at all, at any scope
|
|
7
|
+
# scope_for(user, "leave.approve") — :self, :team, :all or nil
|
|
8
|
+
# reaches?(user, "leave.approve", other) — may they, for THIS person
|
|
9
|
+
#
|
|
10
|
+
# A controller that only asks the first question is the bug this class
|
|
11
|
+
# exists to prevent: before roles, any admin could approve anybody's leave
|
|
12
|
+
# because nothing ever asked whose leave it was.
|
|
13
|
+
class Access
|
|
14
|
+
# Resolution touches three tables and every screen asks several times per
|
|
15
|
+
# request, so it is memoized per user for the life of the request.
|
|
16
|
+
def self.for(user)
|
|
17
|
+
# An unsaved user holds nothing: roles are rows keyed by user id, and
|
|
18
|
+
# there is no id yet to key them by.
|
|
19
|
+
return new(nil, {}) if user.nil? || user.id.nil?
|
|
20
|
+
|
|
21
|
+
Current.access_cache ||= {}
|
|
22
|
+
Current.access_cache[user.id] ||= new(user, resolve(user))
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# The strongest scope held for each key across all of the user's roles.
|
|
26
|
+
# Two roles granting the same key keep the WIDER of the two — roles add
|
|
27
|
+
# up, they do not narrow each other.
|
|
28
|
+
def self.resolve(user)
|
|
29
|
+
grants = RoleGrant.joins(role: :role_assignments)
|
|
30
|
+
.where(hr_lite_role_assignments: { user_id: user.id })
|
|
31
|
+
.pluck(:permission_key, :scope)
|
|
32
|
+
|
|
33
|
+
grants.each_with_object({}) do |(key, scope), map|
|
|
34
|
+
held = map[key]
|
|
35
|
+
map[key] = scope if held.nil? || Permissions.scope_covers?(scope, held)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def initialize(user, permission_map)
|
|
40
|
+
@user = user
|
|
41
|
+
@map = permission_map
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
attr_reader :user
|
|
45
|
+
|
|
46
|
+
def scope_for(key)
|
|
47
|
+
@map[Permissions.validate!(key)]&.to_sym
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def can?(key, scope: :self)
|
|
51
|
+
held = scope_for(key)
|
|
52
|
+
held.present? && Permissions.scope_covers?(held, scope)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Whether this permission reaches a particular person's rows. `self`
|
|
56
|
+
# reaches only the holder; `team` reaches their direct and indirect
|
|
57
|
+
# reports; `all` reaches everyone.
|
|
58
|
+
def reaches?(key, subject_user)
|
|
59
|
+
return false if user.nil? || subject_user.nil?
|
|
60
|
+
|
|
61
|
+
case scope_for(key)
|
|
62
|
+
when :all then true
|
|
63
|
+
when :team then subject_user.id == user.id || report_ids.include?(subject_user.id)
|
|
64
|
+
when :self then subject_user.id == user.id
|
|
65
|
+
else false
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# User ids this person's `team` scope covers, for scoping a whole
|
|
70
|
+
# relation rather than checking one row at a time. `all` returns nil,
|
|
71
|
+
# meaning "do not filter" — a caller that treats nil as an empty list
|
|
72
|
+
# would show leadership nothing, so the callers use `scope_relation`.
|
|
73
|
+
def visible_user_ids(key)
|
|
74
|
+
case scope_for(key)
|
|
75
|
+
when :all then nil
|
|
76
|
+
when :team then [ user.id, *report_ids ]
|
|
77
|
+
when :self then [ user.id ]
|
|
78
|
+
else []
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Narrows a relation to the rows this permission reaches. The column is
|
|
83
|
+
# named because not every table calls it user_id.
|
|
84
|
+
def scope_relation(relation, key, column: :user_id)
|
|
85
|
+
ids = visible_user_ids(key)
|
|
86
|
+
ids.nil? ? relation : relation.where(column => ids)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Everyone below this person in the reporting chain. Walked breadth-first
|
|
90
|
+
# with a seen set — EmployeeProfile validates the chain is acyclic, but a
|
|
91
|
+
# cycle written directly to the database must not spin here forever.
|
|
92
|
+
def report_ids
|
|
93
|
+
@report_ids ||= begin
|
|
94
|
+
by_manager = EmployeeProfile.where.not(manager_id: nil).pluck(:manager_id, :user_id)
|
|
95
|
+
.group_by(&:first)
|
|
96
|
+
.transform_values { |pairs| pairs.map(&:last) }
|
|
97
|
+
found = []
|
|
98
|
+
seen = [ user.id ].to_set
|
|
99
|
+
queue = by_manager[user.id]&.dup || []
|
|
100
|
+
|
|
101
|
+
while (id = queue.shift)
|
|
102
|
+
next unless seen.add?(id)
|
|
103
|
+
|
|
104
|
+
found << id
|
|
105
|
+
queue.concat(by_manager[id] || [])
|
|
106
|
+
end
|
|
107
|
+
found
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
<section class="hrl-card">
|
|
2
|
+
<%= form_with model: role, url: url, method: method, local: true do |f| %>
|
|
3
|
+
<%= render "hr_lite/shared/form_errors", record: role %>
|
|
4
|
+
|
|
5
|
+
<div class="hrl-field">
|
|
6
|
+
<%= f.label :name %>
|
|
7
|
+
<% if role.system? %>
|
|
8
|
+
<%= f.text_field :name, disabled: true %>
|
|
9
|
+
<span class="hrl-small hrl-muted">
|
|
10
|
+
Built-in roles keep their name — the upgrade path and the seed both
|
|
11
|
+
identify them by it. The permissions below are yours to change.
|
|
12
|
+
</span>
|
|
13
|
+
<% else %>
|
|
14
|
+
<%= f.text_field :name %>
|
|
15
|
+
<% end %>
|
|
16
|
+
</div>
|
|
17
|
+
<div class="hrl-field">
|
|
18
|
+
<%= f.label :description, "Description (shown on the roles list)" %>
|
|
19
|
+
<%= f.text_field :description %>
|
|
20
|
+
</div>
|
|
21
|
+
|
|
22
|
+
<% HrLite::Permissions.grouped.each do |group, keys| %>
|
|
23
|
+
<fieldset>
|
|
24
|
+
<legend><%= group %></legend>
|
|
25
|
+
<% keys.each do |key| %>
|
|
26
|
+
<% held = role.grant_map[key] || "none" %>
|
|
27
|
+
<div class="hrl-field">
|
|
28
|
+
<%= label_tag "grants_#{key.tr('.', '_')}", HrLite::Permissions.description(key) %>
|
|
29
|
+
<%= select_tag "grants[#{key}]",
|
|
30
|
+
options_for_select([
|
|
31
|
+
[ "Not granted", "none" ],
|
|
32
|
+
[ "Their own records", "self" ],
|
|
33
|
+
[ "Their team's records", "team" ],
|
|
34
|
+
[ "Everyone's records", "all" ]
|
|
35
|
+
], held),
|
|
36
|
+
id: "grants_#{key.tr('.', '_')}" %>
|
|
37
|
+
</div>
|
|
38
|
+
<% end %>
|
|
39
|
+
</fieldset>
|
|
40
|
+
<% end %>
|
|
41
|
+
|
|
42
|
+
<div class="hrl-form-actions">
|
|
43
|
+
<%= f.submit "Save", class: "hrl-btn hrl-btn--primary" %>
|
|
44
|
+
<a class="hrl-btn" href="<%= hr_lite.admin_roles_path %>">Cancel</a>
|
|
45
|
+
</div>
|
|
46
|
+
<% end %>
|
|
47
|
+
</section>
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
<% content_for(:page_title) { @role.name } %>
|
|
2
|
+
<div class="hrl-page__head">
|
|
3
|
+
<h1 class="hrl-page__title"><%= @role.name %></h1>
|
|
4
|
+
<% unless @role.system? %>
|
|
5
|
+
<div class="hrl-page__actions">
|
|
6
|
+
<%= button_to "Delete role", hr_lite.admin_role_path(@role), method: :delete,
|
|
7
|
+
class: "hrl-btn hrl-btn--danger",
|
|
8
|
+
form: { data: { turbo_confirm: "Delete #{@role.name}? Everyone holding it loses that access." } } %>
|
|
9
|
+
</div>
|
|
10
|
+
<% end %>
|
|
11
|
+
</div>
|
|
12
|
+
|
|
13
|
+
<%= render "form", role: @role, url: hr_lite.admin_role_path(@role), method: :patch %>
|
|
14
|
+
|
|
15
|
+
<section class="hrl-card">
|
|
16
|
+
<h2 class="hrl-card__title">Who holds this role</h2>
|
|
17
|
+
<% assignments = @role.role_assignments.includes(:user).sort_by { |a| hr_display_name(a.user).downcase } %>
|
|
18
|
+
<% if assignments.empty? %>
|
|
19
|
+
<p class="hrl-muted">Nobody yet.</p>
|
|
20
|
+
<% else %>
|
|
21
|
+
<div class="hrl-table-wrap">
|
|
22
|
+
<table class="hrl-table hrl-table--stack">
|
|
23
|
+
<thead><tr><th>Person</th><th>Added</th><th></th></tr></thead>
|
|
24
|
+
<tbody>
|
|
25
|
+
<% assignments.each do |assignment| %>
|
|
26
|
+
<tr>
|
|
27
|
+
<td data-label="Person"><%= hr_display_name(assignment.user) %></td>
|
|
28
|
+
<td data-label="Added"><%= assignment.created_at.to_date.strftime("%d %b %Y") %></td>
|
|
29
|
+
<td data-label="">
|
|
30
|
+
<%= button_to "Remove", hr_lite.admin_role_assignment_path(@role, assignment),
|
|
31
|
+
method: :delete, class: "hrl-btn",
|
|
32
|
+
form: { data: { turbo_confirm: "Remove #{hr_display_name(assignment.user)} from #{@role.name}?" } } %>
|
|
33
|
+
</td>
|
|
34
|
+
</tr>
|
|
35
|
+
<% end %>
|
|
36
|
+
</tbody>
|
|
37
|
+
</table>
|
|
38
|
+
</div>
|
|
39
|
+
<% end %>
|
|
40
|
+
|
|
41
|
+
<%= form_with url: hr_lite.admin_role_assignments_path(@role), method: :post, local: true do %>
|
|
42
|
+
<div class="hrl-field">
|
|
43
|
+
<%= label_tag :user_id, "Add somebody to this role" %>
|
|
44
|
+
<% held = @role.role_assignments.map(&:user_id) %>
|
|
45
|
+
<%= select_tag :user_id,
|
|
46
|
+
options_for_select(HrLite.employees.reject { |u| held.include?(u.id) }
|
|
47
|
+
.map { |u| [ hr_display_name(u), u.id ] }) %>
|
|
48
|
+
</div>
|
|
49
|
+
<div class="hrl-form-actions">
|
|
50
|
+
<%= submit_tag "Add", class: "hrl-btn hrl-btn--primary" %>
|
|
51
|
+
</div>
|
|
52
|
+
<% end %>
|
|
53
|
+
</section>
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
<% content_for(:page_title) { "Roles" } %>
|
|
2
|
+
<div class="hrl-page__head">
|
|
3
|
+
<h1 class="hrl-page__title">Roles & permissions</h1>
|
|
4
|
+
<div class="hrl-page__actions">
|
|
5
|
+
<a class="hrl-btn hrl-btn--primary" href="<%= hr_lite.new_admin_role_path %>">Add role</a>
|
|
6
|
+
</div>
|
|
7
|
+
</div>
|
|
8
|
+
|
|
9
|
+
<section class="hrl-card">
|
|
10
|
+
<p class="hrl-muted hrl-small">
|
|
11
|
+
A permission is what somebody may do; its scope is whose records they may
|
|
12
|
+
do it to — only their own, their reports', or everyone's.
|
|
13
|
+
</p>
|
|
14
|
+
<div class="hrl-table-wrap">
|
|
15
|
+
<table class="hrl-table hrl-table--stack">
|
|
16
|
+
<thead><tr><th>Role</th><th>People</th><th>Reaches</th><th></th></tr></thead>
|
|
17
|
+
<tbody>
|
|
18
|
+
<% @roles.each do |role| %>
|
|
19
|
+
<tr>
|
|
20
|
+
<td data-label="Role">
|
|
21
|
+
<strong><%= role.name %></strong>
|
|
22
|
+
<% if role.system? %><span class="hrl-badge hrl-badge--muted">Built-in</span><% end %>
|
|
23
|
+
<% if role.description.present? %>
|
|
24
|
+
<div class="hrl-small hrl-muted"><%= role.description %></div>
|
|
25
|
+
<% end %>
|
|
26
|
+
</td>
|
|
27
|
+
<td data-label="People" class="hrl-num"><%= role.role_assignments.size %></td>
|
|
28
|
+
<td data-label="Reaches">
|
|
29
|
+
<% company_wide = role.role_grants.count { |g| g.scope == "all" } %>
|
|
30
|
+
<% team = role.role_grants.count { |g| g.scope == "team" } %>
|
|
31
|
+
<%= role.role_grants.size %> permission<%= "s" unless role.role_grants.size == 1 %>
|
|
32
|
+
<%# Joined rather than concatenated: a role with team grants and
|
|
33
|
+
no company-wide ones rendered a leading "·" with nothing
|
|
34
|
+
in front of it. %>
|
|
35
|
+
<% reach = [ ("#{company_wide} company-wide" if company_wide.positive?),
|
|
36
|
+
("#{team} own team" if team.positive?) ].compact %>
|
|
37
|
+
<% if reach.any? %>
|
|
38
|
+
<span class="hrl-small hrl-muted"><%= reach.join(" · ") %></span>
|
|
39
|
+
<% end %>
|
|
40
|
+
</td>
|
|
41
|
+
<td data-label="">
|
|
42
|
+
<a class="hrl-small" href="<%= hr_lite.edit_admin_role_path(role) %>">Edit</a>
|
|
43
|
+
</td>
|
|
44
|
+
</tr>
|
|
45
|
+
<% end %>
|
|
46
|
+
</tbody>
|
|
47
|
+
</table>
|
|
48
|
+
</div>
|
|
49
|
+
</section>
|
data/config/routes.rb
CHANGED
|
@@ -46,6 +46,9 @@ HrLite::Engine.routes.draw do
|
|
|
46
46
|
resources :regularization_requests, only: %i[index show] do
|
|
47
47
|
member { post :approve; post :reject }
|
|
48
48
|
end
|
|
49
|
+
resources :roles, except: :show do
|
|
50
|
+
resources :assignments, only: %i[create destroy], controller: "role_assignments"
|
|
51
|
+
end
|
|
49
52
|
resources :leave_types, except: :show
|
|
50
53
|
resources :office_locations, except: :show
|
|
51
54
|
resources :holidays, only: %i[index create update destroy] do
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
class CreateHrLiteRolesAndGrants < ActiveRecord::Migration[8.1]
|
|
2
|
+
# Roles replace the three configured lambdas (admin_check, leadership_emails,
|
|
3
|
+
# superadmin_emails). Two of those matched a MUTABLE email string — a
|
|
4
|
+
# permission stored in a column the host lets people edit.
|
|
5
|
+
#
|
|
6
|
+
# Permissions themselves are not a table: the vocabulary is declared in
|
|
7
|
+
# HrLite::Permissions::REGISTRY, so a grant is a (role, key, scope) row and
|
|
8
|
+
# an unknown key cannot be stored. That keeps seeding idempotent, and makes
|
|
9
|
+
# retiring a permission a code change with a spec rather than silent data.
|
|
10
|
+
def change
|
|
11
|
+
create_table :hr_lite_roles do |t|
|
|
12
|
+
t.string :name, null: false
|
|
13
|
+
t.string :description
|
|
14
|
+
# Roles the engine seeds and refers to by name. Renaming or deleting one
|
|
15
|
+
# is refused by the model — the upgrade path off the email lists lands
|
|
16
|
+
# people in these, and the roles screen has to keep meaning something.
|
|
17
|
+
t.boolean :system, null: false, default: false
|
|
18
|
+
t.timestamps
|
|
19
|
+
end
|
|
20
|
+
add_index :hr_lite_roles, :name, unique: true
|
|
21
|
+
|
|
22
|
+
create_table :hr_lite_role_grants do |t|
|
|
23
|
+
t.references :role, null: false, index: false,
|
|
24
|
+
foreign_key: { to_table: :hr_lite_roles, on_delete: :cascade }
|
|
25
|
+
t.string :permission_key, null: false
|
|
26
|
+
# self | team | all — see HrLite::Permissions. Stored per grant so one
|
|
27
|
+
# key covers "own leave", "my reports' leave" and "everyone's leave".
|
|
28
|
+
t.string :scope, null: false, default: "self"
|
|
29
|
+
t.timestamps
|
|
30
|
+
end
|
|
31
|
+
# One scope per (role, permission): a role either reaches a set of rows or
|
|
32
|
+
# it does not, and two rows for one key would make that a lookup race.
|
|
33
|
+
add_index :hr_lite_role_grants, %i[role_id permission_key], unique: true
|
|
34
|
+
add_check_constraint :hr_lite_role_grants, "scope IN ('self', 'team', 'all')",
|
|
35
|
+
name: "hr_lite_role_grants_scope_check"
|
|
36
|
+
|
|
37
|
+
create_table :hr_lite_role_assignments do |t|
|
|
38
|
+
# No FK to the host user table — its name is host-specific, the same
|
|
39
|
+
# reason every other user_id in this engine is a bare bigint.
|
|
40
|
+
t.bigint :user_id, null: false
|
|
41
|
+
t.references :role, null: false, index: false,
|
|
42
|
+
foreign_key: { to_table: :hr_lite_roles, on_delete: :cascade }
|
|
43
|
+
t.bigint :granted_by_id
|
|
44
|
+
t.timestamps
|
|
45
|
+
end
|
|
46
|
+
add_index :hr_lite_role_assignments, %i[user_id role_id], unique: true
|
|
47
|
+
add_index :hr_lite_role_assignments, :role_id
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
class SeedHrLiteRolesFromEmailTiers < ActiveRecord::Migration[8.1]
|
|
2
|
+
# The upgrade path off the email lists. On an install that is already
|
|
3
|
+
# running, this is the migration that decides whether anyone loses access,
|
|
4
|
+
# so it is deliberately generous: everybody who could reach something
|
|
5
|
+
# before can reach at least as much afterwards.
|
|
6
|
+
#
|
|
7
|
+
# config.superadmin_emails -> Super Admin
|
|
8
|
+
# config.leadership_emails -> Leadership
|
|
9
|
+
# admin_check == true -> HR
|
|
10
|
+
# everybody else -> Employee
|
|
11
|
+
#
|
|
12
|
+
# It reads the host's OWN configuration, so nothing has to be typed twice,
|
|
13
|
+
# and it says out loud what it did — an upgrade that silently grants the
|
|
14
|
+
# money tier to the wrong person is the failure worth being loud about.
|
|
15
|
+
def up
|
|
16
|
+
require "hr_lite/role_seeds"
|
|
17
|
+
created = HrLite::RoleSeeds.call
|
|
18
|
+
say "Seeded roles: #{created.join(', ')}" if created.any?
|
|
19
|
+
|
|
20
|
+
# A host that has already assigned roles by hand is not re-derived from
|
|
21
|
+
# email lists — that would undo their work.
|
|
22
|
+
if HrLite::RoleAssignment.exists?
|
|
23
|
+
say "Role assignments already exist — leaving them alone."
|
|
24
|
+
return
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
roles = HrLite::Role.where(name: [
|
|
28
|
+
HrLite::Role::EMPLOYEE, HrLite::Role::HR,
|
|
29
|
+
HrLite::Role::LEADERSHIP, HrLite::Role::SUPER_ADMIN
|
|
30
|
+
]).index_by(&:name)
|
|
31
|
+
|
|
32
|
+
# The legacy predicates, read directly rather than through HrLite.admin?
|
|
33
|
+
# — which by now answers off roles, and would say no to everyone.
|
|
34
|
+
superadmins = HrLite.normalize_email_list(HrLite.config.superadmin_emails)
|
|
35
|
+
leaders = HrLite.normalize_email_list(HrLite.config.leadership_emails)
|
|
36
|
+
# Pre-0.5.0 behaviour, still live on hosts that never set the money list:
|
|
37
|
+
# an empty superadmin list meant "the same people as leadership".
|
|
38
|
+
superadmins = leaders if superadmins.empty?
|
|
39
|
+
|
|
40
|
+
assigned = Hash.new { |hash, key| hash[key] = [] }
|
|
41
|
+
|
|
42
|
+
HrLite.config.employees_scope.call.find_each do |user|
|
|
43
|
+
address = user.respond_to?(:email) ? user.email.to_s.downcase.strip : ""
|
|
44
|
+
names = [ HrLite::Role::EMPLOYEE ]
|
|
45
|
+
names << HrLite::Role::SUPER_ADMIN if address.present? && superadmins.include?(address)
|
|
46
|
+
names << HrLite::Role::LEADERSHIP if address.present? && leaders.include?(address)
|
|
47
|
+
names << HrLite::Role::HR if legacy_admin?(user)
|
|
48
|
+
|
|
49
|
+
names.uniq.each do |name|
|
|
50
|
+
role = roles[name] or next
|
|
51
|
+
|
|
52
|
+
HrLite::RoleAssignment.create!(user_id: user.id, role: role)
|
|
53
|
+
assigned[name] << HrLite.display_name(user)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
if assigned.empty?
|
|
58
|
+
say "No users matched employees_scope — assign roles from the Roles screen."
|
|
59
|
+
else
|
|
60
|
+
assigned.each { |name, people| say "#{name}: #{people.sort.join(', ')}" }
|
|
61
|
+
end
|
|
62
|
+
say "Set `config.legacy_tier_checks = true` to keep the old email lists in " \
|
|
63
|
+
"charge for now; it is honoured until 0.7.0."
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Assignments only — the roles themselves stay, because dropping them would
|
|
67
|
+
# take every hand-made grant with them.
|
|
68
|
+
def down
|
|
69
|
+
HrLite::RoleAssignment.delete_all
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
# admin_check is a host lambda over the host's own user model; a host whose
|
|
75
|
+
# users do not answer it must not break the whole upgrade.
|
|
76
|
+
def legacy_admin?(user)
|
|
77
|
+
!!HrLite.config.admin_check.call(user)
|
|
78
|
+
rescue StandardError => e
|
|
79
|
+
say "admin_check raised for #{HrLite.display_name(user)} (#{e.class}) — treated as not HR."
|
|
80
|
+
false
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -7,13 +7,19 @@ HrLite.configure do |c|
|
|
|
7
7
|
c.current_user_method = :current_user
|
|
8
8
|
c.authenticate_method = :authenticate_user!
|
|
9
9
|
|
|
10
|
-
#
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
|
|
10
|
+
# Access is a role table, not configuration — `rake hr_lite:seed` creates
|
|
11
|
+
# the built-in roles and you assign people from /admin/roles. The first
|
|
12
|
+
# person needs Super Admin, which the upgrade migration grants on an
|
|
13
|
+
# existing install; on a fresh one, from a console:
|
|
14
|
+
#
|
|
15
|
+
# HrLite::RoleAssignment.create!(
|
|
16
|
+
# user_id: User.find_by!(email: "you@example.com").id,
|
|
17
|
+
# role: HrLite::Role.find_by!(name: HrLite::Role::SUPER_ADMIN))
|
|
18
|
+
#
|
|
19
|
+
# Upgrading from pre-0.6.0 and not ready to move? Uncomment this and keep
|
|
20
|
+
# your leadership_emails / superadmin_emails / admin_check as they were.
|
|
21
|
+
# It is honoured until 0.7.0.
|
|
22
|
+
# c.legacy_tier_checks = true
|
|
17
23
|
|
|
18
24
|
# Where the portal is reachable (subdomain or path). Enables email link
|
|
19
25
|
# buttons and HrLite.public_url / HrLite.public_url? for deep links.
|
|
@@ -10,7 +10,8 @@ module HrLite
|
|
|
10
10
|
:leadership_emails, :leadership_check, :extra_stylesheets,
|
|
11
11
|
:superadmin_emails, :superadmin_check,
|
|
12
12
|
:mailer_from, :public_url_base, :notification_matrix, :back_link,
|
|
13
|
-
:onboard_user, :offboard_user, :invite_url_for
|
|
13
|
+
:onboard_user, :offboard_user, :invite_url_for,
|
|
14
|
+
:legacy_tier_checks
|
|
14
15
|
|
|
15
16
|
attr_reader :leave_year_start_month
|
|
16
17
|
|
|
@@ -45,6 +46,12 @@ module HrLite
|
|
|
45
46
|
@time_zone = "Asia/Kolkata"
|
|
46
47
|
@currency_symbol = "₹"
|
|
47
48
|
@on_designation_change = ->(user, designation) { }
|
|
49
|
+
# Access came from three lambdas before 0.6.0, two of which matched the
|
|
50
|
+
# user's EMAIL — a mutable, host-owned, unverified column that a host
|
|
51
|
+
# then had to remember never to let anyone edit. Roles replace them.
|
|
52
|
+
# This flag hands authority back to the old lambdas for a host that has
|
|
53
|
+
# not finished migrating; honoured for one minor version, gone in 0.7.0.
|
|
54
|
+
@legacy_tier_checks = false
|
|
48
55
|
@leadership_emails = []
|
|
49
56
|
@leadership_check = ->(user) { HrLite.email_listed?(user, HrLite.config.leadership_emails) }
|
|
50
57
|
# Money tier: salary structures, payroll, slips, appraisals. Empty
|
data/lib/hr_lite/current.rb
CHANGED
|
@@ -3,5 +3,9 @@ module HrLite
|
|
|
3
3
|
# readable from any model callback without threading it through.
|
|
4
4
|
class Current < ActiveSupport::CurrentAttributes
|
|
5
5
|
attribute :actor
|
|
6
|
+
# Resolved permissions, keyed by user id. Every screen asks several times
|
|
7
|
+
# per request and resolution joins three tables; CurrentAttributes is
|
|
8
|
+
# reset between requests, so a role change takes effect on the next one.
|
|
9
|
+
attribute :access_cache
|
|
6
10
|
end
|
|
7
11
|
end
|