hr_lite 0.20.0 → 0.20.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 308cf87cc748758a71ff2bafc7cc301e7ff1fa3f0efc0e0b222e7e4e82e30161
4
- data.tar.gz: 8541bec2dbd4b8c6c1b895065a9580f65de22ce01d128c86c4bc52455aa2bdc0
3
+ metadata.gz: 0d52aa888b9029219bfde72b9d7eabcae22691d9bb5ec73ec6a924191cdf8ac2
4
+ data.tar.gz: b1e9c215421a30a9c521059c90349db86276f0e74f3ac4c7eb4a34a05ca2cd2b
5
5
  SHA512:
6
- metadata.gz: 7b6788e0da35e5695257ebf95e2a25b152a8c42d9659d449197995061b57220d34ae6503986f6aa80497cd55dc74e44d0ea2247126cf48924cead29d2c2f40f8
7
- data.tar.gz: 06d8209caa183f2092611e9d7613b12a7d1cb45e3333a9ad0b0d1027e4f5ed48e6845e9b6766e6ed8f1352ca537c8921bf85e5e23af9b49876b75021273f645b
6
+ metadata.gz: 349fcb87fb86b6ee48633227123a83e712bdd5486cda707de5f3c70d8f2b4df941747610fbf5a817336ece750f4c3d28727559faff4997c840a1fd598be83499
7
+ data.tar.gz: a1c509e0a1189eeac70b67ab848ab04b3507ce85ce456d17396dca9419911187fd26021bbb997d779f07d6d51835e58c105dd4b52f0a7d3ee788899a3781ca9d
data/CHANGELOG.md CHANGED
@@ -7,6 +7,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.20.1] - 2026-10-03
11
+
12
+ Security fixes. No migration.
13
+
14
+ ### Upgrading
15
+
16
+ Document numbers are now encrypted with Active Record Encryption. Rows
17
+ saved before this release are plain text, so the host needs
18
+ `config.active_record.encryption.support_unencrypted_data = true` to keep
19
+ reading them, and should re-save each `HrLite::Document` once to encrypt
20
+ the old numbers.
21
+
22
+ ### Security
23
+
24
+ - **Nobody decides their own request.** A manager could approve their own
25
+ leave, comp-off and attendance fixes, Finance its own expense claims, and
26
+ anyone recording leave for someone else could record their own as
27
+ approved. HR could verify or reject its own documents. All refused now.
28
+ - **Nobody edits their own records from the admin screens.** HR rewriting
29
+ its own attendance, adjusting its own leave balance, or Finance verifying
30
+ its own tax declaration is refused.
31
+ - **Document numbers are encrypted.** Aadhaar, PAN and passport numbers
32
+ were stored in plain text and copied into audit diffs and the leadership
33
+ audit email. Loans join the money tier for audit mail.
34
+ - **A rejected tax declaration no longer lowers TDS.**
35
+ - **Payroll CSVs defuse formulas.** Register and report cells that open
36
+ with `=`, `+`, `-` or `@` are escaped, since names are staff-typed.
37
+ - **The payout register needs `payroll.export`**, not just
38
+ `payroll.manage`.
39
+ - **The admin overview respects reach.** A team-scoped manager sees only
40
+ the people they reach, not the whole company.
41
+ - **Expense receipts** take the same type and size rule as documents.
42
+
10
43
  ## [0.20.0] - 2026-10-01
11
44
 
12
45
  Salary as CTC, and who is out today. No migration.
@@ -82,7 +82,8 @@ module HrLite
82
82
  # authorities, so they resolve through different permissions — a role
83
83
  # can be given the board without being given the pencil.
84
84
  def find_visible_employee = employee_within("attendance.view")
85
- def find_manageable_employee = employee_within("attendance.manage")
85
+ # Nobody rewrites their own punches: team and company scope include the holder.
86
+ def find_manageable_employee = employee_within("attendance.manage").tap { |e| raise ActiveRecord::RecordNotFound if e.id == hr_current_user.id }
86
87
 
87
88
  def employee_within(permission)
88
89
  employee = HrLite.user_klass.find(params[:user_id])
@@ -40,8 +40,9 @@ module HrLite
40
40
  # A manager decides for their own reports; HR decides for everyone.
41
41
  # Scoped through the relation, so reaching for somebody else's report
42
42
  # is a 404 rather than a 403 that confirms the person exists.
43
+ # Nobody decides their own request: team scope includes the approver.
43
44
  def decidable
44
- hr_scope(CompOffRequest.all, "leave.approve")
45
+ hr_scope(CompOffRequest.all, "leave.approve").where.not(user_id: hr_current_user.id)
45
46
  end
46
47
 
47
48
  def find_decidable = decidable.find(params[:id])
@@ -54,6 +54,8 @@ module HrLite
54
54
  # whoever happens to run HR.
55
55
  def find_manageable
56
56
  document = Document.find(params[:id])
57
+ # readable_by? always admits the owner, and nobody verifies their own paperwork.
58
+ raise ActiveRecord::RecordNotFound if document.user_id == hr_current_user.id
57
59
  raise ActiveRecord::RecordNotFound unless document.readable_by?(hr_current_user)
58
60
 
59
61
  document
@@ -39,7 +39,8 @@ module HrLite
39
39
 
40
40
  def require_deciding! = hr_require_permission!("expense.approve", scope: :team)
41
41
 
42
- def decidable = hr_scope(Expense.all, "expense.approve")
42
+ # Nobody approves their own claim: team scope includes the approver.
43
+ def decidable = hr_scope(Expense.all, "expense.approve").where.not(user_id: hr_current_user.id)
43
44
  end
44
45
  end
45
46
  end
@@ -13,6 +13,7 @@ module HrLite
13
13
  # A manager reaches this screen through leave.approve; changing
14
14
  # somebody's quota is leave.manage, for THAT person.
15
15
  hr_require_reach!("leave.manage", user)
16
+ raise ActiveRecord::RecordNotFound if user.id == hr_current_user.id # never one's own quota
16
17
  type = LeaveType.find(params[:leave_type_id])
17
18
  # Unsanitised, a missing param wrote the adjustment into leave year 0,
18
19
  # where no screen can ever show it.
@@ -84,13 +84,14 @@ module HrLite
84
84
  # somebody else's report gets a 404 through the scoped relation rather
85
85
  # than a 403 — the same shape every employee-tier screen already uses,
86
86
  # and it does not confirm that the other person exists.
87
+ # Nobody decides their own request: team scope includes the approver.
87
88
  def decidable
88
- hr_scope(LeaveRequest.all, "leave.approve")
89
+ hr_scope(LeaveRequest.all, "leave.approve").where.not(user_id: hr_current_user.id)
89
90
  end
90
91
 
91
92
  def find_decidable = decidable.find(params[:id])
92
93
 
93
- def recordable = hr_scope(EmployeeProfile.includes(:user), "leave.approve")
94
+ def recordable = hr_scope(EmployeeProfile.includes(:user), "leave.approve").where.not(user_id: hr_current_user.id)
94
95
  end
95
96
  end
96
97
  end
@@ -4,7 +4,7 @@ module HrLite
4
4
  SECTION_CAP = 10
5
5
 
6
6
  def index
7
- @query = OverviewQuery.new
7
+ @query = OverviewQuery.new(user_ids: hr_access.visible_user_ids("leave.view"))
8
8
  @kpis = @query.kpis
9
9
  end
10
10
  end
@@ -1,6 +1,8 @@
1
1
  module HrLite
2
2
  module Admin
3
3
  class PayrollRunsController < SuperadminController
4
+ # The register carries every bank account: its own key, not just payroll.manage.
5
+ before_action -> { hr_require_permission!("payroll.export", scope: :all) }, only: :register
4
6
  def index
5
7
  @runs = paginate(PayrollRun.recent_first)
6
8
  end
@@ -82,6 +84,8 @@ module HrLite
82
84
 
83
85
  def register_csv(run)
84
86
  require "csv"
87
+ # A cell opening with = + - @ runs as a formula; names are staff-typed.
88
+ safe = ->(v) { v.is_a?(String) && v.match?(/\A[=+\-@\t\r]/) ? "'#{v}" : v }
85
89
  CSV.generate do |csv|
86
90
  csv << [ "Code", "Name", "Days", "LOP", "Gross", "PF", "ESI", "PT", "TDS",
87
91
  "Net pay", "Bank", "Account", "IFSC" ]
@@ -95,7 +99,7 @@ module HrLite
95
99
  deductions.dig("esi_employee", "amount") || 0, deductions.dig("pt", "amount") || 0,
96
100
  deductions.dig("tds", "amount") || 0, slip.net_pay,
97
101
  profile&.bank_name, profile&.bank_account_number, profile&.bank_ifsc
98
- ]
102
+ ].map(&safe)
99
103
  end
100
104
  end
101
105
  end
@@ -40,8 +40,9 @@ module HrLite
40
40
  # A manager decides for their own reports; HR decides for everyone.
41
41
  # Scoped through the relation, so reaching for somebody else's report
42
42
  # is a 404 rather than a 403 that confirms the person exists.
43
+ # Nobody decides their own request: team scope includes the approver.
43
44
  def decidable
44
- hr_scope(RegularizationRequest.all, "attendance.manage")
45
+ hr_scope(RegularizationRequest.all, "attendance.manage").where.not(user_id: hr_current_user.id)
45
46
  end
46
47
 
47
48
  def find_decidable = decidable.find(params[:id])
@@ -141,9 +141,11 @@ module HrLite
141
141
  require "csv"
142
142
  return "" if rows.empty?
143
143
 
144
+ # A cell opening with = + - @ runs as a formula; names are staff-typed.
145
+ safe = ->(v) { v.is_a?(String) && v.match?(/\A[=+\-@\t\r]/) ? "'#{v}" : v }
144
146
  CSV.generate do |csv|
145
147
  csv << rows.first.keys
146
- rows.each { |row| csv << row.values }
148
+ rows.each { |row| csv << row.values.map(&safe) }
147
149
  end
148
150
  end
149
151
  end
@@ -62,6 +62,7 @@ module HrLite
62
62
  def find_manageable
63
63
  declaration = TaxDeclaration.find(params[:id])
64
64
  hr_require_reach!("tax.manage", declaration.user)
65
+ raise ActiveRecord::RecordNotFound if declaration.user_id == hr_current_user.id # never one's own
65
66
  declaration
66
67
  end
67
68
 
@@ -14,7 +14,7 @@ module HrLite
14
14
  # leadership audit screen or the policy.changed email.
15
15
  MONEY_TIER_TYPES = %w[
16
16
  HrLite::Appraisal HrLite::DesignationChange HrLite::SalaryStructure
17
- HrLite::PayrollRun HrLite::SalarySlip HrLite::PayrollLineItem
17
+ HrLite::PayrollRun HrLite::SalarySlip HrLite::PayrollLineItem HrLite::Loan
18
18
  ].freeze
19
19
 
20
20
  scope :recent, -> { order(created_at: :desc) }
@@ -5,6 +5,9 @@ module HrLite
5
5
  class Document < ApplicationRecord
6
6
  include Audited
7
7
 
8
+ # An Aadhaar, PAN or passport number: encrypted, which also redacts it from audit diffs and their emails.
9
+ encrypts :reference_number
10
+
8
11
  VISIBILITIES = %w[self hr money].freeze
9
12
  VERIFICATIONS = %w[pending verified rejected].freeze
10
13
 
@@ -23,6 +23,7 @@ module HrLite
23
23
  validate :spent_on_is_not_in_the_future
24
24
  validate :within_the_category_cap, on: :create
25
25
  validate :receipt_is_present_when_the_category_demands_one
26
+ validate :receipt_is_a_small_pdf_or_image
26
27
 
27
28
  scope :awaiting_reimbursement, -> { where(status: "approved") }
28
29
  scope :recent_first, -> { order(spent_on: :desc, id: :desc) }
@@ -138,5 +139,12 @@ module HrLite
138
139
 
139
140
  errors.add(:receipt, "is required for #{category.name}")
140
141
  end
142
+
143
+ def receipt_is_a_small_pdf_or_image
144
+ return unless receipt.attached?
145
+ return errors.add(:receipt, "must be a PDF or an image") unless Document::ALLOWED_TYPES.include?(receipt.blob.content_type)
146
+
147
+ errors.add(:receipt, "must be under #{Document::MAX_BYTES / 1.megabyte} MB") if receipt.blob.byte_size > Document::MAX_BYTES
148
+ end
141
149
  end
142
150
  end
@@ -3,36 +3,38 @@ module HrLite
3
3
  # one source, so they always agree. Sections return Relations (callers
4
4
  # paginate or cap as they see fit).
5
5
  class OverviewQuery
6
- def initialize(date: Date.current)
6
+ # user_ids nil = everyone; a team-scoped viewer sees only the people they reach.
7
+ def initialize(date: Date.current, user_ids: nil)
7
8
  @date = date
9
+ @user_ids = user_ids
8
10
  end
9
11
 
10
12
  def pending_requests
11
- LeaveRequest.pending.includes(:leave_type, :user).order(:created_at)
13
+ reach(LeaveRequest.pending.includes(:leave_type, :user).order(:created_at))
12
14
  end
13
15
 
14
16
  def on_leave_today
15
- LeaveRequest.active_on(@date).includes(:leave_type, :user).order(:start_date)
17
+ reach(LeaveRequest.active_on(@date).includes(:leave_type, :user).order(:start_date))
16
18
  end
17
19
 
18
20
  # Comp-off and regularization are approvals too. Counting leave alone made
19
21
  # the board announce "All present and accounted for" while both queues
20
22
  # still had work in them.
21
23
  def pending_comp_offs
22
- CompOffRequest.pending.includes(:user).recent_first
24
+ reach(CompOffRequest.pending.includes(:user).recent_first)
23
25
  end
24
26
 
25
27
  def pending_regularizations
26
- RegularizationRequest.pending.includes(:user).recent_first
28
+ reach(RegularizationRequest.pending.includes(:user).recent_first)
27
29
  end
28
30
 
29
31
  def flagged_today
30
- AttendanceRecord.for_date(@date).flagged.includes(:user)
32
+ reach(AttendanceRecord.for_date(@date).flagged.includes(:user))
31
33
  end
32
34
 
33
35
  # AttendanceCloseJob fills check_out_at, so the days it closed count too.
34
36
  def missing_checkout_yesterday
35
- yesterday = AttendanceRecord.for_date(@date - 1)
37
+ yesterday = reach(AttendanceRecord.for_date(@date - 1))
36
38
  yesterday.missing_checkout.or(yesterday.where(regularization_note: AttendanceRecord::AUTO_CHECKOUT_NOTE))
37
39
  .includes(:user)
38
40
  end
@@ -49,5 +51,9 @@ module HrLite
49
51
  def empty?
50
52
  kpis.values.all?(&:zero?)
51
53
  end
54
+
55
+ private
56
+
57
+ def reach(relation) = @user_ids ? relation.where(user_id: @user_ids) : relation
52
58
  end
53
59
  end
@@ -135,7 +135,8 @@ module HrLite
135
135
  def annual_deductions
136
136
  declaration = TaxDeclaration.for(@user, @run.period_month)
137
137
  return @profile.declared_annual_deductions if declaration.nil?
138
- return @profile.declared_annual_deductions if declaration.draft?
138
+ # A rejected declaration lowers nothing: its self-declared total stays editable and unchecked.
139
+ return @profile.declared_annual_deductions if declaration.draft? || declaration.rejected?
139
140
 
140
141
  declaration.allowable_total
141
142
  end
@@ -1,3 +1,3 @@
1
1
  module HrLite
2
- VERSION = "0.20.0"
2
+ VERSION = "0.20.1"
3
3
  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.20.0
4
+ version: 0.20.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - kshitiz sinha