From 509ed90fe7e8173abe2e3cbd9b053e75546ccd2d Mon Sep 17 00:00:00 2001 From: weeihan Date: Thu, 23 Jul 2026 12:10:53 +0800 Subject: [PATCH] fix(capa): field-level auth split, status gate, RLS tightening, audit old_value, hide form on pending_verification Co-Authored-By: Claude Sonnet 4.6 --- app/(protected)/capa-owner/page.tsx | 4 +++- app/api/capa/[id]/route.ts | 18 ++++++++++++++++-- .../20260723000002_tighten_capa_update_rls.sql | 6 ++++++ 3 files changed, 25 insertions(+), 3 deletions(-) create mode 100644 supabase/migrations/20260723000002_tighten_capa_update_rls.sql diff --git a/app/(protected)/capa-owner/page.tsx b/app/(protected)/capa-owner/page.tsx index 145772b..5616557 100644 --- a/app/(protected)/capa-owner/page.tsx +++ b/app/(protected)/capa-owner/page.tsx @@ -100,7 +100,9 @@ export default async function CapaOwnerPage() { - + {capa.status !== 'pending_verification' && ( + + )} ) })} diff --git a/app/api/capa/[id]/route.ts b/app/api/capa/[id]/route.ts index 40ec8a8..0bbb014 100644 --- a/app/api/capa/[id]/route.ts +++ b/app/api/capa/[id]/route.ts @@ -53,11 +53,16 @@ export async function PATCH( const { data: profile } = await supabase.from('users').select('role').eq('id', user.id).single() const role = profile?.role ?? '' - const { data: capa } = await supabase.from('capa_actions').select('owner_user_id').eq('id', id).single() + const { data: capa } = await supabase.from('capa_actions').select('owner_user_id, status').eq('id', id).single() const isOwner = capa?.owner_user_id === user.id const canEdit = ['hse', 'admin'].includes(role) || isOwner if (!canEdit) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + const terminalStatuses = ['verified', 'closed'] + if (terminalStatuses.includes(capa?.status ?? '') && !['hse', 'admin'].includes(role)) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + const body = await request.json() // Only hse/admin can set privileged statuses @@ -66,7 +71,9 @@ export async function PATCH( return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) } - const allowed = ['description', 'due_date', 'priority', 'status', 'department', 'root_cause_ref', 'owner_notes'] + const allowed = ['hse', 'admin'].includes(role) + ? ['description', 'due_date', 'priority', 'status', 'department', 'root_cause_ref', 'owner_notes'] + : ['status', 'owner_notes'] const update: Record = {} for (const key of allowed) { if (key in body) update[key] = body[key] @@ -76,6 +83,12 @@ export async function PATCH( update.completed_at = new Date().toISOString() } + let oldNotes: string | null = null + if ('owner_notes' in update) { + const { data: current } = await supabase.from('capa_actions').select('owner_notes').eq('id', id).single() + oldNotes = (current as { owner_notes: string | null } | null)?.owner_notes ?? null + } + const admin = createAdminClient() const { error } = await admin .from('capa_actions') @@ -88,6 +101,7 @@ export async function PATCH( p_table_name: 'capa_actions', p_record_id: id, p_action: 'updated', + p_old_value: oldNotes !== null ? { owner_notes: oldNotes } : undefined, p_new_value: update as Record, }) diff --git a/supabase/migrations/20260723000002_tighten_capa_update_rls.sql b/supabase/migrations/20260723000002_tighten_capa_update_rls.sql new file mode 100644 index 0000000..b15fdb0 --- /dev/null +++ b/supabase/migrations/20260723000002_tighten_capa_update_rls.sql @@ -0,0 +1,6 @@ +-- Tighten capa_update_owner RLS policy: remove department clause so only the +-- assigned owner can UPDATE their own CAPA row directly. Route-level auth +-- enforces field-level restrictions; this ensures DB-level enforcement matches. +DROP POLICY IF EXISTS "capa_update_owner" ON capa_actions; +CREATE POLICY "capa_update_owner" ON capa_actions FOR UPDATE + USING (owner_user_id = auth.uid());