fix(salary): stop RLS from failing vab/parental absence registration (#1568)

Migration 20260517135000 rewrote the franvaro-specifikationsnummer trigger
functions to insert audit rows into salary_absence_franvaro_audit, a table
with RLS enabled and zero policies, while leaving the functions SECURITY
INVOKER (its comment claimed implicit SECURITY DEFINER, which is false in
Postgres). Every vab/parental insert from role authenticated (dashboard
absence POST, web /pending approval, in-app Assistenten chat) then failed
with 42501, surfaced as a generic 500, and left no diagnosable trace.

- New migration 20260813120000: ALTER both trigger functions to SECURITY
  DEFINER with search_path pinned to public, pg_temp. No RLS policy is added
  on the audit table: trigger/service-only writes stay the design intent.
- mapInsertError: 42501 now maps to the new bilingual DB_PERMISSION_DENIED
  code instead of INTERNAL_ERROR, and 23514 is split so only the 24h-cap
  trigger's 'Total tid' message becomes ABSENCE_HOURS_CONFLICT; other CHECK
  violations map to VALIDATION_ERROR.
- commitRegisterAbsence/commitDeleteAbsence: log the underlying PG details
  and persist the sanitized structured code in result_data.error_code so the
  next failure is traceable from the op row.
- Dashboard absence route: only ABSENCE_HOURS_CONFLICT passes details.message
  through to the client; every other code shows the registry Swedish message
  instead of raw Postgres text.
- New pg-real regression test locks the authenticated-role parental/vab
  insert path, the shared per-month specnummer sequence, the audit rows, and
  idempotent upsert retries.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-13 15:12:32 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent ebeaeec80e
commit c4adc8eb7d
9 changed files with 466 additions and 4 deletions
+66
View File
@@ -160,6 +160,72 @@ describe('upsertAbsenceRange', () => {
expect(result.ok).toBe(false)
if (!result.ok) expect(result.code).toBe('ABSENCE_HOURS_CONFLICT')
})
it('maps a non-24h CHECK violation (23514) to VALIDATION_ERROR, not ABSENCE_HOURS_CONFLICT', async () => {
mock.enqueue({ data: { id: EMPLOYEE_ID } })
mock.enqueue({
data: null,
error: {
code: '23514',
message:
'new row for relation "salary_absence_days" violates check constraint "salary_absence_days_hours_check"',
},
})
const result = await upsertAbsenceRange(supabase, {
companyId: COMPANY_ID,
employeeId: EMPLOYEE_ID,
from: '2026-03-02',
to: '2026-03-02',
absenceType: 'sick',
hoursPerDay: 30,
})
expect(result.ok).toBe(false)
if (!result.ok) expect(result.code).toBe('VALIDATION_ERROR')
})
it('maps an RLS/privilege denial (42501) to DB_PERMISSION_DENIED with the PG message in details', async () => {
mock.enqueue({ data: { id: EMPLOYEE_ID } })
mock.enqueue({
data: null,
error: {
code: '42501',
message:
'new row violates row-level security policy for table "salary_absence_franvaro_audit"',
},
})
const result = await upsertAbsenceRange(supabase, {
companyId: COMPANY_ID,
employeeId: EMPLOYEE_ID,
from: '2026-03-02',
to: '2026-03-02',
absenceType: 'parental',
})
expect(result.ok).toBe(false)
if (!result.ok) {
expect(result.code).toBe('DB_PERMISSION_DENIED')
expect(result.details?.message).toMatch(/row-level security/)
}
})
it('keeps unrecognized DB errors as INTERNAL_ERROR', async () => {
mock.enqueue({ data: { id: EMPLOYEE_ID } })
mock.enqueue({ data: null, error: { code: '57014', message: 'canceling statement due to statement timeout' } })
const result = await upsertAbsenceRange(supabase, {
companyId: COMPANY_ID,
employeeId: EMPLOYEE_ID,
from: '2026-03-02',
to: '2026-03-02',
absenceType: 'sick',
})
expect(result.ok).toBe(false)
if (!result.ok) expect(result.code).toBe('INTERNAL_ERROR')
})
})
describe('upsertAbsenceDay', () => {