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:
co-authored by
Jakob Wennberg
Claude Fable 5
parent
ebeaeec80e
commit
c4adc8eb7d
@@ -82,4 +82,43 @@ describe('POST /api/salary/employees/[id]/absence', () => {
|
||||
const response = await POST(post({ absence_date: '2026-07-01', absence_type: 'sick', hours: 8 }), params)
|
||||
expect(response.status).toBe(404)
|
||||
})
|
||||
|
||||
it('does not leak raw PG text for DB failures (42501)', async () => {
|
||||
enqueue({ data: { id: 'emp-1' } }) // loadEmployee
|
||||
enqueue({
|
||||
data: null,
|
||||
error: {
|
||||
code: '42501',
|
||||
message:
|
||||
'new row violates row-level security policy for table "salary_absence_franvaro_audit"',
|
||||
},
|
||||
}) // upsert denied
|
||||
|
||||
const response = await POST(post({ absence_date: '2026-07-01', absence_type: 'parental', hours: 8 }), params)
|
||||
const { status, body } = await parseJsonResponse<{ error: string; code: string }>(response)
|
||||
|
||||
expect(status).toBe(500)
|
||||
expect(body.code).toBe('DB_PERMISSION_DENIED')
|
||||
expect(body.error).not.toMatch(/row-level security/)
|
||||
// The registry's Swedish message is shown instead.
|
||||
expect(body.error).toContain('behörighetsfel')
|
||||
})
|
||||
|
||||
it('still passes the 24h-cap trigger detail through (Swedish, user-facing)', async () => {
|
||||
enqueue({ data: { id: 'emp-1' } }) // loadEmployee
|
||||
enqueue({
|
||||
data: null,
|
||||
error: {
|
||||
code: '23514',
|
||||
message: 'Total tid (arbete + frånvaro) för 2026-07-01 får inte överstiga 24 timmar',
|
||||
},
|
||||
}) // 24h cap trips
|
||||
|
||||
const response = await POST(post({ absence_date: '2026-07-01', absence_type: 'sick', hours: 20 }), params)
|
||||
const { status, body } = await parseJsonResponse<{ error: string; code: string }>(response)
|
||||
|
||||
expect(status).toBe(409)
|
||||
expect(body.code).toBe('ABSENCE_HOURS_CONFLICT')
|
||||
expect(body.error).toContain('Total tid')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,8 +21,13 @@ ensureInitialized()
|
||||
|
||||
function errorResponse(code: string, details?: Record<string, unknown>): NextResponse {
|
||||
const entry = getErrorEntry(code)
|
||||
// Only the 24h-cap trigger's Swedish text is user-facing detail; for every
|
||||
// other code details.message is raw Postgres text and must not reach the
|
||||
// client (the registry message is shown instead).
|
||||
const message =
|
||||
(details?.message as string | undefined) ?? entry?.message_sv ?? 'Något gick fel'
|
||||
(code === 'ABSENCE_HOURS_CONFLICT' ? (details?.message as string | undefined) : undefined) ??
|
||||
entry?.message_sv ??
|
||||
'Något gick fel'
|
||||
return NextResponse.json({ error: message, code }, { status: entry?.httpStatus ?? 500 })
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user