fix: P1 API security hardening — rate limits, auth guards, duplicate prevention

- verify/route.ts: setDate → setUTCDate to avoid timezone off-by-one on recheck date
- triage-suggest, rca-draft, quality-check: 60s per-user rate limit via audit_log
- quality-check: add write_audit_log (was missing, CLAUDE.md violation)
- investigation POST: 409 if investigation already exists for incident
- incidents POST: 60s per-user rate limit via audit_log
- addenda GET: restrict to hse/admin/supervisor roles
- dashboard/stats GET: restrict to hse/admin/management roles

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CPf5Rc8QPx6V8KLEEgfKEQ
This commit is contained in:
2026-07-12 20:47:32 +08:00
co-authored by Claude Sonnet 4.6
parent 1879def32c
commit 16dd62df11
8 changed files with 65 additions and 1 deletions
+1 -1
View File
@@ -31,7 +31,7 @@ export async function POST(
const verifiedAt = new Date()
const recheckDate = new Date(verifiedAt)
recheckDate.setDate(recheckDate.getDate() + 30)
recheckDate.setUTCDate(recheckDate.getUTCDate() + 30)
const update: Record<string, unknown> = {
status: body.verdict,
+4
View File
@@ -8,6 +8,10 @@ export async function GET() {
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const { data: profile } = await supabase.from('users').select('role').eq('id', user.id).single()
if (!profile || !['hse', 'admin', 'management'].includes(profile.role))
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const { data: incidents, error } = await supabase
.from('incidents')
.select('id, status, incident_type, sites (name)')
+4
View File
@@ -13,6 +13,10 @@ export async function GET(
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const { data: profile } = await supabase.from('users').select('role').eq('id', user.id).single()
if (!profile || !['hse', 'admin', 'supervisor'].includes(profile.role))
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const { data, error } = await supabase
.from('incident_addenda')
.select('id, body, created_at, author:users!author (name)')
@@ -18,6 +18,16 @@ export async function POST(
if (!profile || !['hse', 'admin'].includes(profile.role))
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const since = new Date(Date.now() - 60_000).toISOString()
const { count: recentCount } = await supabase
.from('audit_log')
.select('id', { count: 'exact', head: true })
.eq('changed_by', user.id)
.eq('action', 'ai_rca_draft')
.gte('changed_at', since)
if ((recentCount ?? 0) > 0)
return NextResponse.json({ error: 'Rate limited — please wait 60 seconds' }, { status: 429 })
const anthropicKey = await getApiKey(supabase, 'ANTHROPIC_API_KEY')
const anthropic = createAnthropicClient(anthropicKey)
@@ -18,6 +18,16 @@ export async function POST(
if (!profile || !['hse', 'admin'].includes(profile.role))
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const since = new Date(Date.now() - 60_000).toISOString()
const { count: recentCount } = await supabase
.from('audit_log')
.select('id', { count: 'exact', head: true })
.eq('changed_by', user.id)
.eq('action', 'ai_triage_suggest')
.gte('changed_at', since)
if ((recentCount ?? 0) > 0)
return NextResponse.json({ error: 'Rate limited — please wait 60 seconds' }, { status: 429 })
const anthropicKey = await getApiKey(supabase, 'ANTHROPIC_API_KEY')
const anthropic = createAnthropicClient(anthropicKey)
@@ -24,6 +24,13 @@ export async function POST(
if (incident.status !== 'triaged')
return NextResponse.json({ error: 'Incident must be triaged first' }, { status: 409 })
const { count: existingCount } = await supabase
.from('investigations')
.select('id', { count: 'exact', head: true })
.eq('incident_id', id)
if ((existingCount ?? 0) > 0)
return NextResponse.json({ error: 'Investigation already exists for this incident' }, { status: 409 })
const body = await request.json()
const method: 'five_why' | 'fishbone' | 'other' = body.method ?? 'five_why'
@@ -10,6 +10,16 @@ export async function POST(request: NextRequest) {
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const since = new Date(Date.now() - 60_000).toISOString()
const { count: recentCount } = await supabase
.from('audit_log')
.select('id', { count: 'exact', head: true })
.eq('changed_by', user.id)
.eq('action', 'ai_quality_check')
.gte('changed_at', since)
if ((recentCount ?? 0) > 0)
return NextResponse.json({ error: 'Rate limited — please wait 60 seconds' }, { status: 429 })
const anthropicKey = await getApiKey(supabase, 'ANTHROPIC_API_KEY')
const anthropic = createAnthropicClient(anthropicKey)
@@ -81,5 +91,13 @@ Score 110 based on: specificity (location, time, persons involved), completen
) {
return NextResponse.json({ error: 'AI returned unexpected structure' }, { status: 500 })
}
await supabase.rpc('write_audit_log', {
p_table_name: 'incidents',
p_record_id: user.id,
p_action: 'ai_quality_check',
p_new_value: { score: input.score, passes: input.passes, model: 'claude-opus-4-8' } as never,
})
return NextResponse.json(input)
}
+11
View File
@@ -15,6 +15,17 @@ export async function POST(request: Request) {
if (authError || !data?.user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const user = data.user
const since = new Date(Date.now() - 60_000).toISOString()
const { count: recentIncidents } = await supabase
.from('audit_log')
.select('id', { count: 'exact', head: true })
.eq('changed_by', user.id)
.eq('table_name', 'incidents')
.eq('action', 'INSERT')
.gte('changed_at', since)
if ((recentIncidents ?? 0) > 0)
return NextResponse.json({ error: 'Rate limited — please wait 60 seconds before submitting another incident' }, { status: 429 })
let body: Record<string, unknown>
let files: File[] = []