fix: RLS guard in match_incidents + try/catch around AI/embed calls

- Add new migration 20260711000013_match_incidents_auth_guard.sql that
  replaces match_incidents with an inline auth guard: callers without
  hse/admin role receive PGRST301 Forbidden, closing the SECURITY
  DEFINER RLS bypass.
- Wrap anthropic.messages.create() in try/catch returning 503 in all
  four AI routes: quality-check, triage-suggest, rca-draft, similar.
- Wrap JSON.parse(inc.embedding) and embedText() in similar/route.ts
  in a shared try/catch returning 503 Embedding service unavailable.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FFDuBhMKvoWjrWT3ZnGmmr
This commit is contained in:
2026-07-11 16:48:20 +08:00
co-authored by Claude Sonnet 4.6
parent 28b4951b68
commit c07bdb77ad
5 changed files with 160 additions and 98 deletions
+6 -1
View File
@@ -41,7 +41,9 @@ export async function POST(
const siteName = (incident.sites as unknown as { name: string } | null)?.name ?? 'Unknown' const siteName = (incident.sites as unknown as { name: string } | null)?.name ?? 'Unknown'
const zoneName = (incident.zones as unknown as { name: string } | null)?.name ?? 'Unknown' const zoneName = (incident.zones as unknown as { name: string } | null)?.name ?? 'Unknown'
const message = await anthropic.messages.create({ let message: Awaited<ReturnType<typeof anthropic.messages.create>>
try {
message = await anthropic.messages.create({
model: 'claude-opus-4-8', model: 'claude-opus-4-8',
thinking: { type: 'adaptive' }, thinking: { type: 'adaptive' },
max_tokens: 2048, max_tokens: 2048,
@@ -94,6 +96,9 @@ Triage notes: ${inc.triage_notes ?? 'none'}
Provide 35 Why steps drilling from immediate cause to root cause. Give a one-sentence root cause statement. Suggest 3 corrective/preventive actions appropriate for a Malaysian warehouse context.`, Provide 35 Why steps drilling from immediate cause to root cause. Give a one-sentence root cause statement. Suggest 3 corrective/preventive actions appropriate for a Malaysian warehouse context.`,
}], }],
}) })
} catch {
return NextResponse.json({ error: 'AI service unavailable' }, { status: 503 })
}
const toolBlock = message.content.find(b => b.type === 'tool_use') const toolBlock = message.content.find(b => b.type === 'tool_use')
if (!toolBlock || toolBlock.type !== 'tool_use') if (!toolBlock || toolBlock.type !== 'tool_use')
@@ -32,7 +32,9 @@ export async function POST(
medical_status: string | null medical_status: string | null
} }
const message = await anthropic.messages.create({ let message: Awaited<ReturnType<typeof anthropic.messages.create>>
try {
message = await anthropic.messages.create({
model: 'claude-opus-4-8', model: 'claude-opus-4-8',
thinking: { type: 'adaptive' }, thinking: { type: 'adaptive' },
max_tokens: 1024, max_tokens: 1024,
@@ -69,6 +71,9 @@ Asset/equipment involved: ${inc.asset_involved ? 'yes' : 'no'}
Suggest severity (15) and tick the appropriate NADOPOD 2004 flags. Give a one-sentence rationale.`, Suggest severity (15) and tick the appropriate NADOPOD 2004 flags. Give a one-sentence rationale.`,
}], }],
}) })
} catch {
return NextResponse.json({ error: 'AI service unavailable' }, { status: 503 })
}
const toolBlock = message.content.find(b => b.type === 'tool_use') const toolBlock = message.content.find(b => b.type === 'tool_use')
if (!toolBlock || toolBlock.type !== 'tool_use') if (!toolBlock || toolBlock.type !== 'tool_use')
+4
View File
@@ -27,6 +27,7 @@ export async function GET(
const inc = incident as { id: string; description: string; embedding: string | null } const inc = incident as { id: string; description: string; embedding: string | null }
let embeddingVec: number[] let embeddingVec: number[]
try {
if (inc.embedding) { if (inc.embedding) {
embeddingVec = JSON.parse(inc.embedding) as number[] embeddingVec = JSON.parse(inc.embedding) as number[]
} else { } else {
@@ -35,6 +36,9 @@ export async function GET(
embedding: `[${embeddingVec.join(',')}]` as unknown as string, embedding: `[${embeddingVec.join(',')}]` as unknown as string,
}).eq('id', id) }).eq('id', id)
} }
} catch {
return NextResponse.json({ error: 'Embedding service unavailable' }, { status: 503 })
}
const { data: similar } = await supabase.rpc('match_incidents', { const { data: similar } = await supabase.rpc('match_incidents', {
query_embedding: `[${embeddingVec.join(',')}]`, query_embedding: `[${embeddingVec.join(',')}]`,
+6 -1
View File
@@ -19,7 +19,9 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'description and incident_type required' }, { status: 422 }) return NextResponse.json({ error: 'description and incident_type required' }, { status: 422 })
} }
const message = await anthropic.messages.create({ let message: Awaited<ReturnType<typeof anthropic.messages.create>>
try {
message = await anthropic.messages.create({
model: 'claude-opus-4-8', model: 'claude-opus-4-8',
thinking: { type: 'adaptive' }, thinking: { type: 'adaptive' },
max_tokens: 1024, max_tokens: 1024,
@@ -52,6 +54,9 @@ Description: ${body.description}
Score 110 based on: specificity (location, time, persons involved), completeness (what happened + immediate actions), and clarity. Score 6 or above passes. If score is below 6, give up to 3 actionable suggestions.`, Score 110 based on: specificity (location, time, persons involved), completeness (what happened + immediate actions), and clarity. Score 6 or above passes. If score is below 6, give up to 3 actionable suggestions.`,
}], }],
}) })
} catch {
return NextResponse.json({ error: 'AI service unavailable' }, { status: 503 })
}
const toolBlock = message.content.find(b => b.type === 'tool_use') const toolBlock = message.content.find(b => b.type === 'tool_use')
if (!toolBlock || toolBlock.type !== 'tool_use') { if (!toolBlock || toolBlock.type !== 'tool_use') {
@@ -0,0 +1,43 @@
-- Add authorization guard to match_incidents to enforce DB-level access control.
-- Without this, SECURITY DEFINER bypasses RLS for any direct caller.
create or replace function match_incidents(
query_embedding vector(1024),
exclude_id uuid,
match_count int default 5
)
returns table (
id uuid,
reference_no text,
incident_type text,
description text,
severity int,
similarity float
)
language plpgsql
security definer
as $$
begin
-- Enforce that only hse/admin roles can call this function directly
if not exists (
select 1 from public.users
where id = auth.uid()
and role in ('hse', 'admin')
) then
raise exception 'Forbidden' using errcode = 'PGRST301';
end if;
return query
select
i.id,
i.reference_no,
i.incident_type,
i.description,
i.severity,
1 - (i.embedding <=> query_embedding) as similarity
from incidents i
where i.id != exclude_id
and i.embedding is not null
order by i.embedding <=> query_embedding
limit match_count;
end;
$$;