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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bdcc58ee5fdba440b0f70b9f22b85d85d20ef02f16c92481d4aad89a2395489f
4
- data.tar.gz: f705fe091062bbe92246fd19fb1615c9fd9e0f2c6ed34ece293d155cc81879b1
3
+ metadata.gz: 7dd5723f67a89de78893c7ef752581da400b8703fb60876ad7d283e280308d31
4
+ data.tar.gz: d8e517a4a80bb2026de50a9f30b7837b37b785d02a5aea79802ad72b78fcc630
5
5
  SHA512:
6
- metadata.gz: c674e21d22c69a6b2ff5e51cbbb329f166140054212d81a2582c5d0b421350a732c94ae2b8074e4da15f4b510f9a0fa5bfceda84c633b57def295c2066276370
7
- data.tar.gz: 514bf9fa94450d2ccdd4a7443125ec355dd4d68e99c264eac62fe3b5b3be2034a17a64c94571ef51580b97a8ebd59ba85e0a05a55474e3ec80dd9a4ebf798a7d
6
+ metadata.gz: ea7291efd85233dfabf368fdc2355f8050920a22e324ea9f0ac083ba10040aaacaf125f93ed2d89b94e3ab05ed496375d69a6499af66372a974498677a30a18b
7
+ data.tar.gz: 1c1ad921b96ae6d778711b61b62501b5a8fa2e6cfd08cb8fd2c162bc01d37c7f048bc9637a617f1eebcac2f51a01e669cff89d94adf3bd48724b7cb9c7959a05
data/CHANGELOG.md CHANGED
@@ -7,6 +7,55 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.6.0] - 2026-08-18
11
+
12
+ Access stops being three lambdas and becomes a role table. **Breaking**, with
13
+ an upgrade path that is designed not to be: the migration reads your own
14
+ configuration and lands everybody where they already were.
15
+
16
+ **Adds two migrations.** The first creates the role tables; the second seeds
17
+ the built-in roles and derives assignments from your existing email lists,
18
+ printing who it put where.
19
+
20
+ ### Fixed
21
+
22
+ - **Any admin could approve anybody's leave.** Every admin screen gated on a
23
+ tier and then acted on `Model.find(params[:id])`, so whoever could decide
24
+ leave could decide everyone's. `manager_id` sat on the profile driving
25
+ nothing but the org chart. There is now a Manager role at `team` scope,
26
+ every list is narrowed through the relation, and every member action
27
+ re-checks the row.
28
+ - **Two of the three tiers were a string match on `user.email`** — a mutable,
29
+ host-owned, unverified column that each host had to remember never to let
30
+ anybody edit. Roles are rows now.
31
+
32
+ ### Added
33
+
34
+ - `HrLite::Permissions` — the declared vocabulary. A grant is a (role, key,
35
+ scope) row, so an unknown key cannot be stored, and asking about one raises
36
+ rather than quietly answering "no".
37
+ - `HrLite::Access` — resolves what somebody may do and whose rows it reaches,
38
+ memoized per request. `can?`, `reaches?`, `visible_user_ids`,
39
+ `scope_relation`.
40
+ - Controller helpers `hr_can?`, `hr_reaches?`, `hr_require_permission!`,
41
+ `hr_require_reach!`, `hr_scope`.
42
+ - `HrLite.can?`, `HrLite.reaches?`, `HrLite.users_holding` for host code.
43
+ - A Roles screen (`/admin/roles`, behind `role.manage`) for grants and
44
+ assignments. Built-in roles cannot be renamed or deleted, and the last
45
+ person able to manage roles cannot be removed from the role that lets them.
46
+ - `rake hr_lite:roles`, and `hr_lite:seed` now seeds roles too.
47
+
48
+ ### Changed
49
+
50
+ - `HrLite.admin?`, `.leadership?` and `.superadmin?` read off roles. They
51
+ still exist because views, hosts and the notification fan-out call them.
52
+ - `config.legacy_tier_checks` (default `false`) hands authority back to the
53
+ pre-0.6.0 lambdas for a host mid-migration. Honoured for one minor version;
54
+ **removed in 0.7.0**.
55
+ - `HrLite.admin_users` is one query over the grant tables rather than
56
+ instantiating every employee and asking.
57
+ - Branch-coverage floor 90% -> 90.5%.
58
+
10
59
  ## [0.5.3] - 2026-08-17
11
60
 
12
61
  Correctness and traceability pass, ahead of the permissions work in 0.6.0.
@@ -409,7 +458,8 @@ Initial release.
409
458
  bus with per-event channel matrix (bell, email, leadership email/bell),
410
459
  daily leadership digest and an append-only audit trail.
411
460
 
412
- [Unreleased]: https://github.com/kshtzkr/hr_lite/compare/v0.5.3...HEAD
461
+ [Unreleased]: https://github.com/kshtzkr/hr_lite/compare/v0.6.0...HEAD
462
+ [0.6.0]: https://github.com/kshtzkr/hr_lite/compare/v0.5.3...v0.6.0
413
463
  [0.5.3]: https://github.com/kshtzkr/hr_lite/compare/v0.5.2...v0.5.3
414
464
  [0.5.2]: https://github.com/kshtzkr/hr_lite/compare/v0.5.1...v0.5.2
415
465
  [0.5.1]: https://github.com/kshtzkr/hr_lite/compare/v0.5.0...v0.5.1
data/README.md CHANGED
@@ -14,8 +14,8 @@ auth, it does the HR.
14
14
 
15
15
  - Mountable engine, isolated namespace (`HrLite`), tables prefixed `hr_lite_`.
16
16
  - Bring your own users: any user model and auth (Devise or otherwise) via config hooks.
17
- - Access tiers: employee (self-service), admin (day-to-day operations),
18
- leadership (policy and people), and an optional superadmin tier for money.
17
+ - Roles and permissions: a seeded role table you re-cut, where each grant
18
+ carries the scope it reaches their own records, their team's, or everyone's.
19
19
  - One event bus routes every HR event to in-app bells, employee emails and a
20
20
  leadership fan-out, with an append-only audit trail of governing changes.
21
21
  - Self-contained UI: mobile-first, dependency-free JS, theming through CSS
@@ -28,7 +28,7 @@ auth, it does the HR.
28
28
  - [Installation](#installation)
29
29
  - [Quick start (sandbox)](#quick-start-sandbox)
30
30
  - [Configuration](#configuration)
31
- - [Access tiers](#access-tiers)
31
+ - [Roles and permissions](#roles-and-permissions)
32
32
  - [Features](#features)
33
33
  - [Attendance and geolocation](#attendance-and-geolocation)
34
34
  - [Leave, comp-off and regularization](#leave-comp-off-and-regularization)
@@ -148,34 +148,70 @@ end
148
148
  override it, plus the full event/notification matrix. `parent_controller` is
149
149
  resolved once at boot — restart after changing it.
150
150
 
151
- ## Access tiers
152
-
153
- Authorization is layered on top of the checks you provide. There is no separate
154
- role table; a user's tier is derived per request.
155
-
156
- - **Employee** — every signed-in user. All self-service screens are scoped to
157
- `current_user` (asking for someone else's record 404s), so a new hire is
158
- never locked out.
159
- - **Admin** (`admin_check`, or anyone in leadership) day-to-day operations:
160
- team attendance, leave and regularization decisions, the overview board.
161
- - **Leadership** (`leadership_check` only admin is not enough) — policy and
162
- people: leave types, offices, holidays, the weekend rule, employee profiles,
163
- onboarding/offboarding, the audit trail. Every leadership mutation writes an
164
- append-only `hr_lite_audit_logs` row and emails leadership with the diff.
165
- - **Superadmin (money)** (`superadmin_check`) salary structures, payroll
166
- runs, salary-slip administration, appraisals and promotions. Set
167
- `superadmin_emails` to a subset of leadership to separate money from policy.
168
- Left empty (the default), the money tier is the leadership tier.
169
-
170
- Leadership is a list you control, not code. The generated initializer reads it
171
- from `HR_LEADERSHIP_EMAILS` (a comma-separated list) so you can change who
172
- governs without a deploy that env var is a convention in the initializer the
173
- generator writes, not something the gem reads on its own. You can drop the env
174
- var and assign `c.leadership_emails` directly, or replace `c.leadership_check`
175
- to derive leadership some other way.
176
-
177
- `HrLite.admin?(user)`, `HrLite.leadership?(user)` and `HrLite.superadmin?(user)`
178
- are available if you need the same checks in host code.
151
+ ## Roles and permissions
152
+
153
+ Authorization is a **role table**, seeded and then yours to re-cut. A
154
+ permission is a KEY plus a SCOPE:
155
+
156
+ | | |
157
+ |---|---|
158
+ | key | what the action is — `leave.approve`, `payroll.manage`, `attendance.view` |
159
+ | scope | whose records it reaches — `self`, `team` (their reports) or `all` |
160
+
161
+ That is why there is no `leave.approve_own` / `_team` / `_all` triple. One key
162
+ with three possible scopes says the same thing with a third of the surface,
163
+ and it is what makes a **Manager** expressible: `leave.approve` at `team`.
164
+
165
+ Six roles ship. They are a starting point, not a ladder — `hr_lite:seed`
166
+ creates any that are missing and never touches one you have tuned.
167
+
168
+ | Role | Reaches |
169
+ |---|---|
170
+ | Employee | their own leave, attendance, profile and payslips |
171
+ | Manager | the above, plus their reports' leave and attendance |
172
+ | HR | leave, attendance, holidays and tickets for everyone |
173
+ | Finance | payroll, salary structures, the payout register, the money audit |
174
+ | Leadership | people and policy for everyone deliberately **not** pay |
175
+ | Super Admin | everything, including who holds which role |
176
+
177
+ Grants are edited on the Roles screen (`/admin/roles`, behind `role.manage`).
178
+ Built-in roles cannot be renamed or deleted — the seed and the upgrade path
179
+ identify them by name — but every permission inside them can change. The last
180
+ person who can manage roles cannot be removed from the role that lets them;
181
+ there is no way back from that without a console.
182
+
183
+ ### In your own code
184
+
185
+ ```ruby
186
+ HrLite.can?(user, "leave.approve", scope: :team) # may they, at all?
187
+ HrLite.reaches?(user, "leave.approve", employee) # may they, for THIS person?
188
+ HrLite.users_holding("payroll.manage") # everyone who can
189
+ ```
190
+
191
+ Controllers get `hr_can?`, `hr_reaches?`, `hr_require_permission!`,
192
+ `hr_require_reach!` and `hr_scope(relation, key)`. Reaching a screen and
193
+ reaching a particular person's row are separate questions — an index is
194
+ narrowed with `hr_scope` and each member action re-checks with
195
+ `hr_require_reach!`, which 404s rather than 403s so a stranger does not learn
196
+ that the other person exists.
197
+
198
+ `HrLite.admin?`, `HrLite.leadership?` and `HrLite.superadmin?` still work and
199
+ are read off roles.
200
+
201
+ ### Upgrading from the email lists (pre-0.6.0)
202
+
203
+ Access used to come from three lambdas, two of which matched the user's
204
+ `email` — a mutable, host-owned, unverified column that every host then had to
205
+ remember never to let anybody edit.
206
+
207
+ The upgrade migration reads your existing configuration and lands everybody
208
+ where they already were: `superadmin_emails` to Super Admin,
209
+ `leadership_emails` to Leadership, `admin_check` to HR, everyone to Employee.
210
+ It prints who it put where, and it leaves a host that has already assigned
211
+ roles by hand alone.
212
+
213
+ Set `config.legacy_tier_checks = true` to keep the old lambdas in charge while
214
+ you migrate. It is honoured for one minor version and removed in 0.7.0.
179
215
 
180
216
  ## Features
181
217
 
@@ -4,14 +4,18 @@ module HrLite
4
4
  # Team day view: everyone × their punch/status for one date.
5
5
  def index
6
6
  @date = parse_date_param(params[:date])
7
+ # A manager's board is their reports; HR's is the company. Both read
8
+ # the same screen — the difference is one permission scope.
9
+ visible = hr_access.visible_user_ids("attendance.view")
7
10
  @employees = HrLite.employees
11
+ @employees = @employees.select { |user| visible.include?(user.id) } if visible
8
12
  @records = AttendanceRecord.for_date(@date).where(user_id: @employees.map(&:id)).index_by(&:user_id)
9
13
  @flagged_count = @records.values.count(&:flagged?)
10
14
  end
11
15
 
12
16
  # One employee's month + the regularization form for ?date=.
13
17
  def show
14
- @employee = HrLite.user_klass.find(params[:user_id])
18
+ @employee = find_visible_employee
15
19
  @month = parse_month_param(params[:month])
16
20
  @day_status = DayStatus.new(user: @employee, range: @month.beginning_of_month..@month.end_of_month)
17
21
  @counts = @day_status.counts
@@ -23,7 +27,7 @@ module HrLite
23
27
  # Clearing both punch times deletes the record (that is how an
24
28
  # erroneous punch is removed).
25
29
  def update
26
- @employee = HrLite.user_klass.find(params[:user_id])
30
+ @employee = find_manageable_employee
27
31
  date = parse_date_param(params[:date])
28
32
  record = AttendanceRecord.find_or_initialize_by(user_id: @employee.id, date: date)
29
33
 
@@ -74,6 +78,18 @@ module HrLite
74
78
 
75
79
  private
76
80
 
81
+ # Viewing somebody's month and rewriting their punches are different
82
+ # authorities, so they resolve through different permissions — a role
83
+ # can be given the board without being given the pencil.
84
+ def find_visible_employee = employee_within("attendance.view")
85
+ def find_manageable_employee = employee_within("attendance.manage")
86
+
87
+ def employee_within(permission)
88
+ employee = HrLite.user_klass.find(params[:user_id])
89
+ hr_require_reach!(permission, employee)
90
+ employee
91
+ end
92
+
77
93
  def log_regularization(record, note, removed:)
78
94
  AuditLog.record!(
79
95
  action: removed ? "destroy" : "regularize", subject: record, actor: hr_current_user,
@@ -1,15 +1,25 @@
1
1
  module HrLite
2
2
  module Admin
3
- # Day-to-day operations tier: overview board, team attendance,
4
- # leave decisions, balance adjustments. Leadership members are
5
- # allowed too — governing implies at least operational visibility.
3
+ # Day-to-day operations: the overview board, team attendance, leave
4
+ # decisions, balance adjustments.
5
+ #
6
+ # The gate is now "can you decide somebody's leave, or fix somebody's
7
+ # attendance" rather than "are you an admin". That is what admits a
8
+ # MANAGER to these screens for the first time — at `team` scope, which
9
+ # every controller below then enforces row by row. Reaching the screen
10
+ # and reaching a particular person's row are two separate questions, and
11
+ # conflating them is precisely the bug this replaces.
6
12
  class BaseController < ApplicationController
7
- before_action :require_hr_admin!
13
+ before_action :require_operations_access!
8
14
 
9
15
  private
10
16
 
11
- def require_hr_admin!
12
- hr_access_denied unless hr_admin? || hr_leadership?
17
+ def require_operations_access!
18
+ return if hr_can?("leave.approve", scope: :team) ||
19
+ hr_can?("attendance.manage", scope: :team) ||
20
+ hr_can?("leave.view", scope: :team)
21
+
22
+ hr_access_denied
13
23
  end
14
24
  end
15
25
  end
@@ -3,15 +3,16 @@ module HrLite
3
3
  class CompOffRequestsController < BaseController
4
4
  def index
5
5
  @status = params[:status].presence_in(CompOffRequest::STATUSES) || "pending"
6
- @requests = paginate(CompOffRequest.includes(:user).where(status: @status).recent_first)
6
+ @requests = paginate(hr_scope(CompOffRequest.includes(:user).where(status: @status).recent_first,
7
+ "leave.view"))
7
8
  end
8
9
 
9
10
  def show
10
- @request = CompOffRequest.find(params[:id])
11
+ @request = find_decidable
11
12
  end
12
13
 
13
14
  def approve
14
- request = CompOffRequest.find(params[:id])
15
+ request = find_decidable
15
16
  request.approve!(actor: hr_current_user, note: params[:decision_note].presence)
16
17
  redirect_to admin_comp_off_requests_path,
17
18
  notice: "Approved — #{request.credit_days.to_f} day credited."
@@ -22,7 +23,7 @@ module HrLite
22
23
  end
23
24
 
24
25
  def reject
25
- request = CompOffRequest.find(params[:id])
26
+ request = find_decidable
26
27
  note = params[:decision_note].to_s.strip
27
28
  if note.blank?
28
29
  return redirect_to admin_comp_off_request_path(request), alert: "A note is required to reject."
@@ -33,6 +34,17 @@ module HrLite
33
34
  rescue ActiveRecord::RecordInvalid
34
35
  redirect_to admin_comp_off_request_path(request), alert: "Only pending requests can be decided."
35
36
  end
37
+
38
+ private
39
+
40
+ # A manager decides for their own reports; HR decides for everyone.
41
+ # Scoped through the relation, so reaching for somebody else's report
42
+ # is a 404 rather than a 403 that confirms the person exists.
43
+ def decidable
44
+ hr_scope(CompOffRequest.all, "leave.approve")
45
+ end
46
+
47
+ def find_decidable = decidable.find(params[:id])
36
48
  end
37
49
  end
38
50
  end
@@ -1,17 +1,20 @@
1
1
  module HrLite
2
2
  module Admin
3
- # Governing tier: anything that changes policy or money — leave types,
4
- # settings, office locations, holidays, employee profiles, salary
5
- # structures, payroll, appraisals, audit trail. Leadership ONLY;
6
- # being a host-app admin is not enough.
3
+ # Governing screens: policy and people for the whole company — leave
4
+ # types, settings, office locations, holidays, employee profiles,
5
+ # resignations, the audit trail.
6
+ #
7
+ # `profile.manage` at `all` is the key, because governing is exactly the
8
+ # authority to change somebody else's record. Reaching an operations
9
+ # screen is not enough, and neither is managing a team.
7
10
  class LeadershipController < BaseController
8
- skip_before_action :require_hr_admin!
9
- before_action :require_hr_leadership!
11
+ skip_before_action :require_operations_access!
12
+ before_action :require_governing_access!
10
13
 
11
14
  private
12
15
 
13
- def require_hr_leadership!
14
- hr_access_denied unless hr_leadership?
16
+ def require_governing_access!
17
+ hr_require_permission!("profile.manage", scope: :all)
15
18
  end
16
19
  end
17
20
  end
@@ -4,16 +4,20 @@ module HrLite
4
4
  def index
5
5
  scope = LeaveRequest.includes(:leave_type, :user).recent_first
6
6
  @status = params[:status].presence_in(LeaveRequest::STATUSES) || "pending"
7
- @requests = paginate(scope.where(status: @status))
7
+ # A manager's queue is their own reports. HR's is everybody's. Scoping
8
+ # the LIST as well as the decision matters: an index that shows a row
9
+ # the member action then refuses is a worse screen than one that never
10
+ # showed it.
11
+ @requests = paginate(hr_scope(scope.where(status: @status), "leave.view"))
8
12
  end
9
13
 
10
14
  def show
11
- @request = LeaveRequest.includes(:leave_type).find(params[:id])
15
+ @request = decidable.includes(:leave_type).find(params[:id])
12
16
  @balance = @request.balance
13
17
  end
14
18
 
15
19
  def approve
16
- request = LeaveRequest.find(params[:id])
20
+ request = find_decidable
17
21
  if request.approve!(actor: hr_current_user, note: params[:decision_note].presence)
18
22
  redirect_to admin_leave_requests_path, notice: "Leave approved."
19
23
  else
@@ -30,7 +34,7 @@ module HrLite
30
34
  # while the person was unreachable, or after they were offboarded, could
31
35
  # not be released at all, and the quota stayed spent.
32
36
  def cancel
33
- request = LeaveRequest.find(params[:id])
37
+ request = find_decidable
34
38
  if request.cancellable_by?(hr_current_user)
35
39
  request.cancel!(actor: hr_current_user)
36
40
  redirect_to admin_leave_requests_path, notice: "Leave cancelled — the balance is released."
@@ -41,7 +45,7 @@ module HrLite
41
45
  end
42
46
 
43
47
  def reject
44
- request = LeaveRequest.find(params[:id])
48
+ request = find_decidable
45
49
  note = params[:decision_note].to_s.strip
46
50
  if note.blank?
47
51
  return redirect_to admin_leave_request_path(request), alert: "A note is required to reject."
@@ -52,6 +56,18 @@ module HrLite
52
56
  rescue ActiveRecord::RecordInvalid
53
57
  redirect_to admin_leave_request_path(request), alert: "Only pending requests can be decided."
54
58
  end
59
+
60
+ private
61
+
62
+ # Requests this person may actually decide. A manager reaching for
63
+ # somebody else's report gets a 404 through the scoped relation rather
64
+ # than a 403 — the same shape every employee-tier screen already uses,
65
+ # and it does not confirm that the other person exists.
66
+ def decidable
67
+ hr_scope(LeaveRequest.all, "leave.approve")
68
+ end
69
+
70
+ def find_decidable = decidable.find(params[:id])
55
71
  end
56
72
  end
57
73
  end
@@ -3,15 +3,16 @@ module HrLite
3
3
  class RegularizationRequestsController < BaseController
4
4
  def index
5
5
  @status = params[:status].presence_in(RegularizationRequest::STATUSES) || "pending"
6
- @requests = paginate(RegularizationRequest.includes(:user).where(status: @status).recent_first)
6
+ @requests = paginate(hr_scope(RegularizationRequest.includes(:user).where(status: @status).recent_first,
7
+ "attendance.view"))
7
8
  end
8
9
 
9
10
  def show
10
- @request = RegularizationRequest.find(params[:id])
11
+ @request = find_decidable
11
12
  end
12
13
 
13
14
  def approve
14
- request = RegularizationRequest.find(params[:id])
15
+ request = find_decidable
15
16
  request.approve!(actor: hr_current_user, note: params[:decision_note].presence)
16
17
  redirect_to admin_regularization_requests_path, notice: "Ticket approved — attendance fixed."
17
18
  rescue RegularizationRequest::InvalidMerge => e
@@ -22,7 +23,7 @@ module HrLite
22
23
  end
23
24
 
24
25
  def reject
25
- request = RegularizationRequest.find(params[:id])
26
+ request = find_decidable
26
27
  note = params[:decision_note].to_s.strip
27
28
  if note.blank?
28
29
  return redirect_to admin_regularization_request_path(request), alert: "A note is required to reject."
@@ -33,6 +34,17 @@ module HrLite
33
34
  rescue ActiveRecord::RecordInvalid
34
35
  redirect_to admin_regularization_request_path(request), alert: "Only pending tickets can be decided."
35
36
  end
37
+
38
+ private
39
+
40
+ # A manager decides for their own reports; HR decides for everyone.
41
+ # Scoped through the relation, so reaching for somebody else's report
42
+ # is a 404 rather than a 403 that confirms the person exists.
43
+ def decidable
44
+ hr_scope(RegularizationRequest.all, "attendance.manage")
45
+ end
46
+
47
+ def find_decidable = decidable.find(params[:id])
36
48
  end
37
49
  end
38
50
  end
@@ -0,0 +1,63 @@
1
+ module HrLite
2
+ module Admin
3
+ # Putting people into a role and taking them out again. Same gate as the
4
+ # roles themselves: granting somebody payroll is the same act as holding
5
+ # it, so it sits behind `role.manage`.
6
+ class RoleAssignmentsController < LeadershipController
7
+ skip_before_action :require_governing_access!
8
+ before_action :require_role_management!
9
+
10
+ def create
11
+ role = Role.find(params[:role_id])
12
+ user = HrLite.user_klass.find(params[:user_id])
13
+ RoleAssignment.create!(role: role, user_id: user.id, granted_by_id: hr_current_user.id)
14
+
15
+ redirect_to edit_admin_role_path(role),
16
+ notice: "#{HrLite.display_name(user)} added to #{role.name}."
17
+ rescue ActiveRecord::RecordInvalid
18
+ redirect_to edit_admin_role_path(params[:role_id]),
19
+ alert: "They already hold that role."
20
+ end
21
+
22
+ def destroy
23
+ assignment = RoleAssignment.find(params[:id])
24
+ role = assignment.role
25
+ name = HrLite.display_name(assignment.user)
26
+
27
+ # Locking every last person out of role management is not recoverable
28
+ # from inside the app — it would need a console. Refuse instead.
29
+ if last_role_manager?(assignment)
30
+ return redirect_to edit_admin_role_path(role),
31
+ alert: "#{name} is the only person who can manage roles. " \
32
+ "Give somebody else that permission first."
33
+ end
34
+
35
+ assignment.destroy!
36
+ redirect_to edit_admin_role_path(role), notice: "#{name} removed from #{role.name}.",
37
+ status: :see_other
38
+ end
39
+
40
+ private
41
+
42
+ def require_role_management!
43
+ hr_require_permission!("role.manage", scope: :all)
44
+ end
45
+
46
+ def last_role_manager?(assignment)
47
+ return false unless HrLite.can?(assignment.user, "role.manage", scope: :all)
48
+
49
+ others = HrLite.users_holding("role.manage").where.not(id: assignment.user_id)
50
+ # Another of their OWN roles may still carry it, in which case
51
+ # removing this one changes nothing.
52
+ others.empty? && !still_manages_without?(assignment)
53
+ end
54
+
55
+ def still_manages_without?(assignment)
56
+ RoleGrant.joins(role: :role_assignments)
57
+ .where(hr_lite_role_assignments: { user_id: assignment.user_id })
58
+ .where.not(hr_lite_role_assignments: { id: assignment.id })
59
+ .exists?(permission_key: "role.manage", scope: "all")
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,73 @@
1
+ module HrLite
2
+ module Admin
3
+ # Who may do what. Gated on `role.manage`, which by default only the
4
+ # money tier holds — the authority to grant yourself payroll is the
5
+ # authority to read everyone's pay, so it lives with the pay.
6
+ class RolesController < LeadershipController
7
+ skip_before_action :require_governing_access!
8
+ before_action :require_role_management!
9
+
10
+ def index
11
+ @roles = Role.alphabetical.includes(:role_grants, :role_assignments)
12
+ end
13
+
14
+ def new
15
+ @role = Role.new
16
+ end
17
+
18
+ def create
19
+ @role = Role.new(role_params)
20
+ if @role.save
21
+ @role.replace_grants!(grant_params)
22
+ redirect_to admin_roles_path, notice: "#{@role.name} created."
23
+ else
24
+ render :new, status: :unprocessable_entity
25
+ end
26
+ end
27
+
28
+ def edit
29
+ @role = Role.find(params[:id])
30
+ end
31
+
32
+ def update
33
+ @role = Role.find(params[:id])
34
+ if @role.update(role_params)
35
+ @role.replace_grants!(grant_params)
36
+ redirect_to admin_roles_path, notice: "#{@role.name} updated."
37
+ else
38
+ render :edit, status: :unprocessable_entity
39
+ end
40
+ end
41
+
42
+ def destroy
43
+ role = Role.find(params[:id])
44
+ if role.destroy
45
+ redirect_to admin_roles_path, notice: "#{role.name} deleted.", status: :see_other
46
+ else
47
+ redirect_to admin_roles_path, alert: role.errors.full_messages.to_sentence,
48
+ status: :see_other
49
+ end
50
+ end
51
+
52
+ private
53
+
54
+ def require_role_management!
55
+ hr_require_permission!("role.manage", scope: :all)
56
+ end
57
+
58
+ def role_params
59
+ permitted = params.require(:role).permit(:name, :description)
60
+ # A built-in role's NAME is what the seed, the upgrade migration and
61
+ # the specs all identify it by; the model refuses a rename, and not
62
+ # sending one keeps the form from failing on an untouched field.
63
+ @role&.system? ? permitted.except(:name) : permitted
64
+ end
65
+
66
+ # The form posts the COMPLETE picture, so a permission absent from it is
67
+ # one being taken away. "none" is how the form says "not granted".
68
+ def grant_params
69
+ params.fetch(:grants, {}).permit(Permissions::KEYS).to_h
70
+ end
71
+ end
72
+ end
73
+ end
@@ -1,20 +1,24 @@
1
1
  module HrLite
2
2
  module Admin
3
- # Money tier: salary structures, payroll, slips, appraisals and
4
- # promotions. Only config.superadmin_emailsordinary leadership can
5
- # govern policy and people but never sees another person's pay.
6
- # The money tier stands on its own rather than layering on leadership: a
7
- # payroll operator does not have to also govern people and policy.
8
- # Inheriting the leadership check locked a configured superadmin who was
9
- # absent from leadership_emails out of payroll entirely.
3
+ # Money screens: salary structures, payroll, slips, appraisals and
4
+ # promotions. Ordinary governing authority reaches none of it — leadership
5
+ # governs policy and people but never sees another person's pay.
6
+ #
7
+ # The money gate stands on its own rather than layering on the governing
8
+ # one: a payroll operator does not have to also govern people and policy.
9
+ # Stacking the two locked a configured money-tier user who was absent from
10
+ # the leadership list out of payroll entirely.
10
11
  class SuperadminController < LeadershipController
11
- skip_before_action :require_hr_leadership!
12
- before_action :require_hr_superadmin!
12
+ skip_before_action :require_governing_access!
13
+ before_action :require_money_access!
13
14
 
14
15
  private
15
16
 
16
- def require_hr_superadmin!
17
- hr_access_denied unless hr_superadmin?
17
+ # Deliberately payroll.manage rather than a "super admin" role name:
18
+ # roles are data an install re-cuts, and gating on a NAME would make
19
+ # renaming one a silent lock-out.
20
+ def require_money_access!
21
+ hr_require_permission!("payroll.manage", scope: :all)
18
22
  end
19
23
  end
20
24
  end