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
@@ -0,0 +1,79 @@
1
+ module HrLite
2
+ # The whole vocabulary of the engine's authorization, in one place.
3
+ #
4
+ # A permission is a KEY plus a SCOPE. The key says what the action is
5
+ # ("approve leave"); the scope says whose rows it reaches:
6
+ #
7
+ # self — only this person's own records
8
+ # team — the people who report to them (EmployeeProfile#manager_id)
9
+ # all — everyone in the company
10
+ #
11
+ # That is why there is no `leave.approve_own` / `leave.approve_team` /
12
+ # `leave.approve_all` triple: one key with three possible scopes says the
13
+ # same thing without three times the surface to get wrong.
14
+ #
15
+ # Keys are DATA — a role grants them from the database. They are declared
16
+ # here so that a typo in a controller is a boot-time failure rather than a
17
+ # permission that silently never matches.
18
+ module Permissions
19
+ SCOPES = %i[self team all].freeze
20
+
21
+ # Ordered weakest to strongest. `all` satisfies a `team` requirement,
22
+ # `team` satisfies `self`; never the other way round.
23
+ SCOPE_RANK = { self: 0, team: 1, all: 2 }.freeze
24
+
25
+ # key => [ group, description ]. The group is what the roles screen
26
+ # renders as a heading; the description is the sentence beside the
27
+ # checkbox, so it is written for the person granting it.
28
+ REGISTRY = {
29
+ "profile.view" => [ "People", "See employee profiles" ],
30
+ "profile.manage" => [ "People", "Create and edit employee profiles, onboard and offboard" ],
31
+ "attendance.view" => [ "Attendance", "See attendance records" ],
32
+ "attendance.manage" => [ "Attendance", "Correct punches and decide regularization tickets" ],
33
+ "leave.request" => [ "Leave", "Apply for leave and comp-off" ],
34
+ "leave.view" => [ "Leave", "See leave requests and balances" ],
35
+ "leave.approve" => [ "Leave", "Approve, reject and cancel leave and comp-off" ],
36
+ "leave.manage" => [ "Leave", "Adjust balances, configure leave types and holidays" ],
37
+ "payroll.view" => [ "Payroll", "See payroll runs and salary slips" ],
38
+ "payroll.manage" => [ "Payroll", "Create, compute, finalize, unlock and publish payroll" ],
39
+ "payroll.export" => [ "Payroll", "Download the payout register, including bank details" ],
40
+ "salary.view" => [ "Payroll", "See salary structures" ],
41
+ "salary.manage" => [ "Payroll", "Set and revise salary structures" ],
42
+ "appraisal.view" => [ "Growth", "See appraisals" ],
43
+ "appraisal.manage" => [ "Growth", "Write, share and act on appraisals and promotions" ],
44
+ "resignation.view" => [ "Lifecycle", "See resignations" ],
45
+ "resignation.manage" => [ "Lifecycle", "Accept resignations and set the last working day" ],
46
+ "settings.manage" => [ "Administration", "Change company settings, offices and policy" ],
47
+ "audit.view" => [ "Administration", "Read the audit trail" ],
48
+ "audit.view_money" => [ "Administration", "Read audit rows about pay, appraisals and promotions" ],
49
+ "role.manage" => [ "Administration", "Create roles and grant permissions" ]
50
+ }.freeze
51
+
52
+ KEYS = REGISTRY.keys.freeze
53
+
54
+ def self.valid?(key) = REGISTRY.key?(key.to_s)
55
+
56
+ # Raises rather than returning false: an unknown key in a controller is a
57
+ # typo that would otherwise read as "nobody may do this", which fails in
58
+ # the safe direction but silently, and is then very hard to find.
59
+ def self.validate!(key)
60
+ return key.to_s if valid?(key)
61
+
62
+ raise ArgumentError, "Unknown HrLite permission #{key.inspect}. " \
63
+ "Declare it in HrLite::Permissions::REGISTRY first."
64
+ end
65
+
66
+ def self.group(key) = REGISTRY.fetch(key.to_s).first
67
+ def self.description(key) = REGISTRY.fetch(key.to_s).last
68
+
69
+ def self.grouped
70
+ REGISTRY.group_by { |_key, (group, _)| group }
71
+ .transform_values { |pairs| pairs.map(&:first) }
72
+ end
73
+
74
+ # Does `held` satisfy a requirement for `needed`?
75
+ def self.scope_covers?(held, needed)
76
+ SCOPE_RANK.fetch(held.to_sym) >= SCOPE_RANK.fetch(needed.to_sym)
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,81 @@
1
+ module HrLite
2
+ # The six roles an install starts with. They are a STARTING POINT, not a
3
+ # ladder in code: an install is expected to edit the grants, and `roles:seed`
4
+ # never overwrites a role that already exists.
5
+ #
6
+ # Read the scopes as the interesting part. Manager and HR hold the same
7
+ # leave.approve key — the difference between them is `team` and `all`, which
8
+ # is the whole reason scope lives on the grant.
9
+ module RoleSeeds
10
+ # A method, not a constant: the keys are Role constants, and this file is
11
+ # required while the gem loads, long before Active Record models exist.
12
+ def self.definitions
13
+ {
14
+ Role::EMPLOYEE => {
15
+ description: "Self-service only: own attendance, leave, documents and payslips.",
16
+ grants: {
17
+ "leave.request" => "self", "leave.view" => "self",
18
+ "attendance.view" => "self", "payroll.view" => "self",
19
+ "profile.view" => "self", "appraisal.view" => "self",
20
+ "resignation.view" => "self"
21
+ }
22
+ },
23
+ Role::MANAGER => {
24
+ description: "Everything an employee has, plus their reports' attendance and leave.",
25
+ grants: {
26
+ "leave.request" => "self", "leave.view" => "team", "leave.approve" => "team",
27
+ "attendance.view" => "team", "attendance.manage" => "team",
28
+ "payroll.view" => "self", "profile.view" => "team",
29
+ "appraisal.view" => "self", "resignation.view" => "self"
30
+ }
31
+ },
32
+ Role::HR => {
33
+ description: "Day-to-day operations for everyone: attendance, leave, holidays, tickets.",
34
+ grants: {
35
+ "leave.request" => "self", "leave.view" => "all", "leave.approve" => "all",
36
+ "leave.manage" => "all", "attendance.view" => "all", "attendance.manage" => "all",
37
+ "profile.view" => "all", "payroll.view" => "self",
38
+ "appraisal.view" => "self", "resignation.view" => "all"
39
+ }
40
+ },
41
+ Role::FINANCE => {
42
+ description: "Payroll and pay data. No authority over people or policy.",
43
+ grants: {
44
+ "leave.request" => "self", "leave.view" => "self",
45
+ "attendance.view" => "all", "profile.view" => "all",
46
+ "payroll.view" => "all", "payroll.manage" => "all", "payroll.export" => "all",
47
+ "salary.view" => "all", "salary.manage" => "all",
48
+ "audit.view" => "all", "audit.view_money" => "all"
49
+ }
50
+ },
51
+ Role::LEADERSHIP => {
52
+ description: "People and policy for everyone — deliberately NOT pay.",
53
+ grants: {
54
+ "leave.request" => "self", "leave.view" => "all", "leave.approve" => "all",
55
+ "leave.manage" => "all", "attendance.view" => "all", "attendance.manage" => "all",
56
+ "profile.view" => "all", "profile.manage" => "all",
57
+ "resignation.view" => "all", "resignation.manage" => "all",
58
+ "settings.manage" => "all", "audit.view" => "all", "payroll.view" => "self"
59
+ }
60
+ },
61
+ Role::SUPER_ADMIN => {
62
+ description: "Everything, including pay, appraisals and who holds which role.",
63
+ grants: Permissions::KEYS.to_h { |key| [ key, "all" ] }
64
+ }
65
+ }.freeze
66
+ end
67
+
68
+ # Creates any role that does not exist yet and leaves every existing one
69
+ # exactly as the install has tuned it — the same contract as the leave-type
70
+ # seed. Returns the names it created.
71
+ def self.call
72
+ definitions.filter_map do |name, definition|
73
+ next if Role.exists?(name: name)
74
+
75
+ role = Role.create!(name: name, description: definition[:description], system: true)
76
+ role.replace_grants!(definition[:grants])
77
+ name
78
+ end
79
+ end
80
+ end
81
+ end
@@ -1,3 +1,3 @@
1
1
  module HrLite
2
- VERSION = "0.5.3"
2
+ VERSION = "0.6.0"
3
3
  end
data/lib/hr_lite.rb CHANGED
@@ -4,6 +4,8 @@ require "hr_lite/configuration"
4
4
  require "hr_lite/current"
5
5
  require "hr_lite/leave_year"
6
6
  require "hr_lite/financial_year"
7
+ require "hr_lite/permissions"
8
+ require "hr_lite/role_seeds"
7
9
  require "hr_lite/mention_parser"
8
10
  require "hr_lite/notifications"
9
11
  require "hr_lite/seeds"
@@ -31,16 +33,49 @@ module HrLite
31
33
  config.user_class.constantize
32
34
  end
33
35
 
36
+ # --- authorization -----------------------------------------------------
37
+
38
+ # The question every gate now asks. `scope:` is what the CALLER needs;
39
+ # a holder with a wider scope satisfies it.
40
+ def can?(user, key, scope: :self)
41
+ Access.for(user).can?(key, scope: scope)
42
+ end
43
+
44
+ # Whether `user` may exercise `key` over `subject`'s rows. This is the
45
+ # question that did not exist before roles, and its absence is why any
46
+ # admin could approve anybody's leave.
47
+ def reaches?(user, key, subject)
48
+ Access.for(user).reaches?(key, subject)
49
+ end
50
+
51
+ def access_for(user) = Access.for(user)
52
+
53
+ # --- legacy tier predicates --------------------------------------------
54
+ #
55
+ # Kept because views, hosts and the notification fan-out all call them.
56
+ # They are now READ OFF ROLES rather than off a mutable email column; the
57
+ # configured lambdas survive only as an explicit opt-in for a host that
58
+ # has not migrated yet (see Configuration#legacy_tier_checks).
59
+
34
60
  def admin?(user)
35
- user.present? && !!config.admin_check.call(user)
61
+ return false if user.blank?
62
+ return !!config.admin_check.call(user) if config.legacy_tier_checks
63
+
64
+ can?(user, "leave.approve", scope: :all) || can?(user, "attendance.manage", scope: :all)
36
65
  end
37
66
 
38
67
  def leadership?(user)
39
- user.present? && !!config.leadership_check.call(user)
68
+ return false if user.blank?
69
+ return !!config.leadership_check.call(user) if config.legacy_tier_checks
70
+
71
+ can?(user, "profile.manage", scope: :all)
40
72
  end
41
73
 
42
74
  def superadmin?(user)
43
- user.present? && !!config.superadmin_check.call(user)
75
+ return false if user.blank?
76
+ return !!config.superadmin_check.call(user) if config.legacy_tier_checks
77
+
78
+ can?(user, "payroll.manage", scope: :all)
44
79
  end
45
80
 
46
81
  # An access list, cleaned. "a@x.com,,b@x.com".split(",") — one stray
@@ -60,21 +95,39 @@ module HrLite
60
95
  end
61
96
 
62
97
  # Leadership members resolvable to actual user records (for bell
63
- # notifications). Emails configured but absent from the user table are
64
- # still reachable by email — see Notifications.
98
+ # notifications). On a legacy host, emails configured but absent from the
99
+ # user table are still reachable by email — see Notifications.
65
100
  def leadership_users
101
+ unless config.legacy_tier_checks
102
+ return users_holding("profile.manage", scope: :all)
103
+ end
104
+
66
105
  emails = normalize_email_list(config.leadership_emails)
67
106
  return user_klass.none if emails.empty?
68
107
 
69
108
  user_klass.where("LOWER(#{user_klass.table_name}.email) IN (?)", emails)
70
109
  end
71
110
 
72
- # Every domain event that bells the admins calls this. Loading and
73
- # instantiating the entire users table to run admin_check per row is fine
74
- # at ten staff and silly at a thousand — so it starts from employees_scope
75
- # (which a host narrows to real staff) rather than every row that exists.
111
+ # Everyone whose roles grant `key` at `scope` or wider. One query over
112
+ # the grant tables rather than instantiating every user and asking.
113
+ def users_holding(key, scope: :all)
114
+ wide = Permissions::SCOPE_RANK.select { |_, rank| rank >= Permissions::SCOPE_RANK.fetch(scope) }
115
+ ids = RoleAssignment.joins(role: :role_grants)
116
+ .where(hr_lite_role_grants: {
117
+ permission_key: Permissions.validate!(key), scope: wide.keys.map(&:to_s)
118
+ })
119
+ .distinct.pluck(:user_id)
120
+ user_klass.where(id: ids)
121
+ end
122
+
123
+ # Every domain event that bells the admins calls this. On roles it is one
124
+ # query over the grant tables; on a legacy host it still has to run the
125
+ # host's lambda per row, which is why it starts from employees_scope
126
+ # (narrowed to real staff) rather than every row that exists.
76
127
  def admin_users
77
- employees.select { |u| admin?(u) }
128
+ return employees.select { |u| admin?(u) } if config.legacy_tier_checks
129
+
130
+ users_holding("leave.approve", scope: :all).sort_by { |u| display_name(u).downcase }
78
131
  end
79
132
 
80
133
  # Everyone HR tracks (host-overridable to exclude bots/test accounts),
@@ -1,8 +1,16 @@
1
1
  namespace :hr_lite do
2
- desc "Idempotently seed HR reference data (leave types, national holidays)"
2
+ desc "Idempotently seed HR reference data (leave types, national holidays, roles)"
3
3
  task seed: :environment do
4
4
  require "hr_lite/seeds"
5
- created = HrLite::Seeds.run!
5
+ require "hr_lite/role_seeds"
6
+ created = HrLite::Seeds.run! + HrLite::RoleSeeds.call
6
7
  puts created.any? ? "hr_lite:seed created: #{created.join(', ')}" : "hr_lite:seed — nothing to do"
7
8
  end
9
+
10
+ desc "Idempotently seed the built-in roles only (never overwrites a tuned role)"
11
+ task roles: :environment do
12
+ require "hr_lite/role_seeds"
13
+ created = HrLite::RoleSeeds.call
14
+ puts created.any? ? "hr_lite:roles created: #{created.join(', ')}" : "hr_lite:roles — nothing to do"
15
+ end
8
16
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hr_lite
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.3
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - kshitiz sinha
@@ -72,6 +72,8 @@ files:
72
72
  - app/controllers/hr_lite/admin/payroll_runs_controller.rb
73
73
  - app/controllers/hr_lite/admin/regularization_requests_controller.rb
74
74
  - app/controllers/hr_lite/admin/resignations_controller.rb
75
+ - app/controllers/hr_lite/admin/role_assignments_controller.rb
76
+ - app/controllers/hr_lite/admin/roles_controller.rb
75
77
  - app/controllers/hr_lite/admin/salary_slips_controller.rb
76
78
  - app/controllers/hr_lite/admin/salary_structures_controller.rb
77
79
  - app/controllers/hr_lite/admin/settings_controller.rb
@@ -120,9 +122,13 @@ files:
120
122
  - app/models/hr_lite/payroll_run.rb
121
123
  - app/models/hr_lite/regularization_request.rb
122
124
  - app/models/hr_lite/resignation.rb
125
+ - app/models/hr_lite/role.rb
126
+ - app/models/hr_lite/role_assignment.rb
127
+ - app/models/hr_lite/role_grant.rb
123
128
  - app/models/hr_lite/salary_slip.rb
124
129
  - app/models/hr_lite/salary_structure.rb
125
130
  - app/models/hr_lite/setting.rb
131
+ - app/services/hr_lite/access.rb
126
132
  - app/services/hr_lite/attendance_puncher.rb
127
133
  - app/services/hr_lite/attendance_summary.rb
128
134
  - app/services/hr_lite/calculators/esi.rb
@@ -170,6 +176,10 @@ files:
170
176
  - app/views/hr_lite/admin/payroll_runs/show.html.erb
171
177
  - app/views/hr_lite/admin/regularization_requests/index.html.erb
172
178
  - app/views/hr_lite/admin/regularization_requests/show.html.erb
179
+ - app/views/hr_lite/admin/roles/_form.html.erb
180
+ - app/views/hr_lite/admin/roles/edit.html.erb
181
+ - app/views/hr_lite/admin/roles/index.html.erb
182
+ - app/views/hr_lite/admin/roles/new.html.erb
173
183
  - app/views/hr_lite/admin/salary_slips/show.html.erb
174
184
  - app/views/hr_lite/admin/salary_structures/_form.html.erb
175
185
  - app/views/hr_lite/admin/salary_structures/edit.html.erb
@@ -243,6 +253,8 @@ files:
243
253
  - db/migrate/20260807184426_add_fy_opening_to_hr_lite_employee_profiles.rb
244
254
  - db/migrate/20260807184939_add_out_of_window_days_to_hr_lite_salary_slips.rb
245
255
  - db/migrate/20260817182124_add_status_constraints_and_appraisal_fk_to_hr_lite.rb
256
+ - db/migrate/20260817183347_create_hr_lite_roles_and_grants.rb
257
+ - db/migrate/20260817183629_seed_hr_lite_roles_from_email_tiers.rb
246
258
  - lib/generators/hr_lite/install/install_generator.rb
247
259
  - lib/generators/hr_lite/install/templates/AFTER_INSTALL
248
260
  - lib/generators/hr_lite/install/templates/initializer.rb
@@ -257,6 +269,8 @@ files:
257
269
  - lib/hr_lite/mention_parser.rb
258
270
  - lib/hr_lite/money.rb
259
271
  - lib/hr_lite/notifications.rb
272
+ - lib/hr_lite/permissions.rb
273
+ - lib/hr_lite/role_seeds.rb
260
274
  - lib/hr_lite/seeds.rb
261
275
  - lib/hr_lite/statutory_rate_card.rb
262
276
  - lib/hr_lite/version.rb