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.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +51 -1
  3. data/README.md +67 -31
  4. data/app/controllers/hr_lite/admin/attendances_controller.rb +18 -2
  5. data/app/controllers/hr_lite/admin/base_controller.rb +16 -6
  6. data/app/controllers/hr_lite/admin/comp_off_requests_controller.rb +16 -4
  7. data/app/controllers/hr_lite/admin/leadership_controller.rb +11 -8
  8. data/app/controllers/hr_lite/admin/leave_requests_controller.rb +21 -5
  9. data/app/controllers/hr_lite/admin/regularization_requests_controller.rb +16 -4
  10. data/app/controllers/hr_lite/admin/role_assignments_controller.rb +63 -0
  11. data/app/controllers/hr_lite/admin/roles_controller.rb +73 -0
  12. data/app/controllers/hr_lite/admin/superadmin_controller.rb +15 -11
  13. data/app/controllers/hr_lite/application_controller.rb +39 -1
  14. data/app/helpers/hr_lite/application_helper.rb +5 -2
  15. data/app/models/hr_lite/role.rb +66 -0
  16. data/app/models/hr_lite/role_assignment.rb +18 -0
  17. data/app/models/hr_lite/role_grant.rb +15 -0
  18. data/app/services/hr_lite/access.rb +111 -0
  19. data/app/views/hr_lite/admin/roles/_form.html.erb +47 -0
  20. data/app/views/hr_lite/admin/roles/edit.html.erb +53 -0
  21. data/app/views/hr_lite/admin/roles/index.html.erb +49 -0
  22. data/app/views/hr_lite/admin/roles/new.html.erb +3 -0
  23. data/config/routes.rb +3 -0
  24. data/db/migrate/20260817183347_create_hr_lite_roles_and_grants.rb +49 -0
  25. data/db/migrate/20260817183629_seed_hr_lite_roles_from_email_tiers.rb +82 -0
  26. data/lib/generators/hr_lite/install/templates/initializer.rb +13 -7
  27. data/lib/hr_lite/configuration.rb +8 -1
  28. data/lib/hr_lite/current.rb +4 -0
  29. data/lib/hr_lite/permissions.rb +79 -0
  30. data/lib/hr_lite/role_seeds.rb +81 -0
  31. data/lib/hr_lite/version.rb +1 -1
  32. data/lib/hr_lite.rb +63 -10
  33. data/lib/tasks/hr_lite_tasks.rake +10 -2
  34. 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 tier only config.superadmin_emails see these.
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 &amp; 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>
@@ -0,0 +1,3 @@
1
+ <% content_for(:page_title) { "New role" } %>
2
+ <div class="hrl-page__head"><h1 class="hrl-page__title">New role</h1></div>
3
+ <%= render "form", role: @role, url: hr_lite.admin_roles_path, method: :post %>
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
- # Operations tier (team attendance, leave decisions, overview board).
11
- c.admin_check = ->(user) { user.respond_to?(:admin?) && user.admin? }
12
-
13
- # Governing tier ONLY these people change policy, employee profiles,
14
- # salary structures, payroll and appraisals. Keep it in an env var so
15
- # changing leadership never needs a deploy.
16
- c.leadership_emails = ENV.fetch("HR_LEADERSHIP_EMAILS", "").split(",").map(&:strip)
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
@@ -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