hr_lite 0.5.2 → 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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +112 -1
  3. data/README.md +67 -31
  4. data/app/controllers/hr_lite/admin/attendances_controller.rb +21 -6
  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_balances_controller.rb +4 -5
  9. data/app/controllers/hr_lite/admin/leave_requests_controller.rb +21 -5
  10. data/app/controllers/hr_lite/admin/payroll_runs_controller.rb +11 -2
  11. data/app/controllers/hr_lite/admin/regularization_requests_controller.rb +16 -4
  12. data/app/controllers/hr_lite/admin/role_assignments_controller.rb +63 -0
  13. data/app/controllers/hr_lite/admin/roles_controller.rb +73 -0
  14. data/app/controllers/hr_lite/admin/salary_slips_controller.rb +5 -6
  15. data/app/controllers/hr_lite/admin/superadmin_controller.rb +15 -11
  16. data/app/controllers/hr_lite/application_controller.rb +39 -1
  17. data/app/helpers/hr_lite/application_helper.rb +5 -2
  18. data/app/models/hr_lite/audit_log.rb +26 -1
  19. data/app/models/hr_lite/comp_off_request.rb +3 -1
  20. data/app/models/hr_lite/leave_request.rb +27 -4
  21. data/app/models/hr_lite/payroll_run.rb +30 -4
  22. data/app/models/hr_lite/regularization_request.rb +3 -4
  23. data/app/models/hr_lite/role.rb +66 -0
  24. data/app/models/hr_lite/role_assignment.rb +18 -0
  25. data/app/models/hr_lite/role_grant.rb +15 -0
  26. data/app/models/hr_lite/salary_slip.rb +1 -2
  27. data/app/services/hr_lite/access.rb +111 -0
  28. data/app/services/hr_lite/payroll_run_processor.rb +4 -1
  29. data/app/views/hr_lite/admin/roles/_form.html.erb +47 -0
  30. data/app/views/hr_lite/admin/roles/edit.html.erb +53 -0
  31. data/app/views/hr_lite/admin/roles/index.html.erb +49 -0
  32. data/app/views/hr_lite/admin/roles/new.html.erb +3 -0
  33. data/config/routes.rb +3 -0
  34. data/db/migrate/20260817182124_add_status_constraints_and_appraisal_fk_to_hr_lite.rb +63 -0
  35. data/db/migrate/20260817183347_create_hr_lite_roles_and_grants.rb +49 -0
  36. data/db/migrate/20260817183629_seed_hr_lite_roles_from_email_tiers.rb +82 -0
  37. data/lib/generators/hr_lite/install/templates/initializer.rb +13 -7
  38. data/lib/hr_lite/configuration.rb +11 -7
  39. data/lib/hr_lite/current.rb +4 -0
  40. data/lib/hr_lite/financial_year.rb +26 -0
  41. data/lib/hr_lite/permissions.rb +79 -0
  42. data/lib/hr_lite/role_seeds.rb +81 -0
  43. data/lib/hr_lite/statutory_rate_card.rb +45 -5
  44. data/lib/hr_lite/version.rb +1 -1
  45. data/lib/hr_lite.rb +81 -11
  46. data/lib/tasks/hr_lite_tasks.rake +10 -2
  47. metadata +17 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d22267458cedf1755a676934b2488e404e78e26ea9c2b803f9dd72ee828b620d
4
- data.tar.gz: 50605b10283eb03f12bde5884917c033aebf58e7afc968de790f9581ff6cf8d4
3
+ metadata.gz: 7dd5723f67a89de78893c7ef752581da400b8703fb60876ad7d283e280308d31
4
+ data.tar.gz: d8e517a4a80bb2026de50a9f30b7837b37b785d02a5aea79802ad72b78fcc630
5
5
  SHA512:
6
- metadata.gz: 7cc91aa10c5d70555bfedb8870d3d87cac4b06ea762222b768a120abae7ed5f46d94c96091aee92a397eb2390e5432611ede42076b78d02c060dcba4da3ebd53
7
- data.tar.gz: 5e850af90388970e343448c95533a7251ce0834400f33c6570feef5eddb511621e888d9b7c28b1cbb6391bb70941730e1f2804470d9c1bf1a5439ea7edc85c38
6
+ metadata.gz: ea7291efd85233dfabf368fdc2355f8050920a22e324ea9f0ac083ba10040aaacaf125f93ed2d89b94e3ab05ed496375d69a6499af66372a974498677a30a18b
7
+ data.tar.gz: 1c1ad921b96ae6d778711b61b62501b5a8fa2e6cfd08cb8fd2c162bc01d37c7f048bc9637a617f1eebcac2f51a01e669cff89d94adf3bd48724b7cb9c7959a05
data/CHANGELOG.md CHANGED
@@ -7,6 +7,115 @@ 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
+
59
+ ## [0.5.3] - 2026-08-17
60
+
61
+ Correctness and traceability pass, ahead of the permissions work in 0.6.0.
62
+ **Adds one migration** — check constraints on every status column, plus an
63
+ index and foreign key on `hr_lite_designation_changes.appraisal_id`. It
64
+ refuses to run, naming the table and the offending values, if a live row
65
+ already holds a status no model declares.
66
+
67
+ ### Fixed
68
+
69
+ - **Payroll ran silently on an out-of-date statutory card.** The lookup fell
70
+ back to the newest card on or before the run month with no signal, so a run
71
+ in a financial year the gem ships no card for computed PF, ESI, PT and TDS
72
+ on the previous year's figures and said nothing. It still falls back —
73
+ payroll cannot stop dead every 1 April — but `PayrollRunProcessor` now puts
74
+ a warning naming both financial years at the TOP of the run's warnings, on
75
+ every compute. Adding a year remains one new entry in
76
+ `StatutoryRateCard::CARDS`; rates are never inferred forward.
77
+ - **A blank email could be granted the leadership tier.** `leadership_check`
78
+ did not drop empty entries the way `superadmin_check` did, so one stray
79
+ comma in `HR_LEADERSHIP_EMAILS` ("a@x.com,,b@x.com") put `""` on the list
80
+ and any user whose email was blank or nil matched it. Both checks now share
81
+ `HrLite.email_listed?`, which refuses a blank address against any list.
82
+ - Comp-off approval no longer reaches for a plural that cannot occur — a
83
+ credit is 0.5 or 1 day and never more.
84
+
85
+ ### Added
86
+
87
+ - **The money path leaves a trail.** Payroll compute, finalize, unlock and
88
+ publish, every leave approval, rejection and cancellation, and the payout
89
+ register download now write `AuditLog` rows. Previously a run could be
90
+ computed, finalized, unlocked, edited and republished and the audit screen
91
+ showed none of it.
92
+ - These rows are written inside the same transaction as the change they
93
+ describe and RAISE on failure, unlike the `Audited` concern, which stays
94
+ best-effort for ordinary policy edits. A payroll transition nobody can
95
+ account for afterwards does not happen at all.
96
+ - Amounts are deliberately excluded — `audit_logs` is not encrypted. Rows
97
+ record who moved a run, how far, and over how many people. Leave rows
98
+ carry the approver's note and never the employee's own reason.
99
+ - `HrLite::PayrollRun` and `HrLite::SalarySlip` join `MONEY_TIER_TYPES`, so
100
+ payroll rows stay out of the leadership audit screen.
101
+ - `HrLite::AuditLog.record!` — the five-line `create!` that five call sites
102
+ had each written out.
103
+ - `HrLite::FinancialYear` — the Indian FY (1 April) worked out in one place
104
+ instead of three.
105
+
106
+ ### Changed
107
+
108
+ - Statuses are enforced by the database. `update_column`, `update_all`,
109
+ `insert_all` and console fixes can no longer write a value that no screen
110
+ renders and no transition accepts.
111
+ - The suite enforces its own coverage: 100% line, 90% branch, 90% per file.
112
+ The README claimed 100% and nothing checked it; switching the floor on found
113
+ eight untested paths, including all three slip-override money guards (LOP
114
+ above the days in the month, negative LOP, negative TDS) and the offboarding
115
+ error path. All are covered now.
116
+ - `docs/PAYROLL.md` no longer lists ESI contribution-period lock-in as
117
+ unmodelled — 0.5.2 implemented it.
118
+
10
119
  ## [0.5.2] - 2026-08-08
11
120
 
12
121
  The rest of the audit that produced 0.5.1. **Adds three migrations** — they
@@ -349,7 +458,9 @@ Initial release.
349
458
  bus with per-event channel matrix (bell, email, leadership email/bell),
350
459
  daily leadership digest and an append-only audit trail.
351
460
 
352
- [Unreleased]: https://github.com/kshtzkr/hr_lite/compare/v0.5.2...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
463
+ [0.5.3]: https://github.com/kshtzkr/hr_lite/compare/v0.5.2...v0.5.3
353
464
  [0.5.2]: https://github.com/kshtzkr/hr_lite/compare/v0.5.1...v0.5.2
354
465
  [0.5.1]: https://github.com/kshtzkr/hr_lite/compare/v0.5.0...v0.5.1
355
466
  [0.5.0]: https://github.com/kshtzkr/hr_lite/compare/v0.4.0...v0.5.0
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,11 +78,22 @@ 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
- AuditLog.create!(
79
- actor: hr_current_user, action: removed ? "destroy" : "regularize",
80
- subject_type: record.class.name, subject_id: record.id || 0,
81
- audited_changes: { "date" => record.date.to_s, "note" => note }
94
+ AuditLog.record!(
95
+ action: removed ? "destroy" : "regularize", subject: record, actor: hr_current_user,
96
+ changes: { "date" => record.date.to_s, "note" => note }
82
97
  )
83
98
  Notifications.publish(
84
99
  "attendance.regularized",
@@ -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
@@ -23,11 +23,10 @@ module HrLite
23
23
 
24
24
  balance = LeaveBalance.adjust!(user, type, year, delta: delta, note: "#{delta.to_f} — #{note}")
25
25
 
26
- AuditLog.create!(
27
- actor: hr_current_user, action: "adjust",
28
- subject_type: balance.class.name, subject_id: balance.id,
29
- audited_changes: { "user" => HrLite.display_name(user), "type" => type.code,
30
- "delta" => delta.to_f, "note" => note }
26
+ AuditLog.record!(
27
+ action: "adjust", subject: balance, actor: hr_current_user,
28
+ changes: { "user" => HrLite.display_name(user), "type" => type.code,
29
+ "delta" => delta.to_f, "note" => note }
31
30
  )
32
31
  redirect_to admin_leave_balances_path(year: year),
33
32
  notice: "Balance adjusted for #{HrLite.display_name(user)}."
@@ -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
@@ -55,8 +55,17 @@ module HrLite
55
55
  # deliberately leadership-only PII.
56
56
  def register
57
57
  run = PayrollRun.find(params[:id])
58
- send_data register_csv(run), filename: "payroll-register-#{run.period_month.strftime('%Y-%m')}.csv",
59
- type: "text/csv"
58
+ csv = register_csv(run)
59
+ # A GET that walks out of the building with every employee's pay and
60
+ # bank account. Nothing else in the module leaks this much in one
61
+ # click, so the download itself is the auditable event.
62
+ AuditLog.record!(
63
+ action: "payroll.register_exported", subject: run, actor: hr_current_user,
64
+ changes: { "period" => run.label, "rows" => run.salary_slips.count,
65
+ "includes_bank_details" => true }
66
+ )
67
+ send_data csv, filename: "payroll-register-#{run.period_month.strftime('%Y-%m')}.csv",
68
+ type: "text/csv"
60
69
  end
61
70
 
62
71
  private
@@ -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