wacrm/src/app/api/automations/[id]/duplicate/route.ts
Arnas Donauskas 1d7829e68a fix(security): enforce agent role on service-role write routes (GHSA-34q7-fv77-625j)
Flows and automations write routes verify only authentication/membership
(via the RLS client, whose *_select policies have no minimum role) and
then mutate with the service-role client, which bypasses the agent-gated
*_insert/update/delete RLS policies. A read-only `viewer` therefore had
full write power.

H3 (High): add requireRole('agent') to the flows write handlers —
PUT/DELETE (flows/[id]), activate (flows/[id]/activate), and create
(flows POST). GET/list stay open to viewers.

M2 (Medium): same gate on automations POST, the engine trigger
(automations/engine, which sends outbound WhatsApp), and the [id]
mutation handlers (PATCH/DELETE/duplicate). The cron route is unchanged —
it is gated by the x-cron-secret shared secret, not user auth.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 09:35:20 +03:00

87 lines
3.0 KiB
TypeScript

import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { requireRole, toErrorResponse } from '@/lib/auth/account'
import { supabaseAdmin } from '@/lib/automations/admin-client'
export async function POST(
_request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params
// Duplicating creates a new automation row — a write. Enforce `agent`
// (the service-role client below bypasses the agent-gated
// automations_insert RLS).
try {
await requireRole('agent')
} catch (err) {
return toErrorResponse(err)
}
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const admin = supabaseAdmin()
const { data: original, error: origErr } = await admin
.from('automations')
.select('*')
.eq('id', id)
.eq('user_id', user.id)
.maybeSingle()
if (origErr) return NextResponse.json({ error: origErr.message }, { status: 500 })
if (!original) return NextResponse.json({ error: 'Not found' }, { status: 404 })
const { data: copy, error: copyErr } = await admin
.from('automations')
.insert({
// Clone into the same account as the original. account_id is NOT
// NULL post-017, so the INSERT fails the constraint without it.
account_id: original.account_id,
user_id: user.id,
name: `${original.name} (Copy)`,
description: original.description,
trigger_type: original.trigger_type,
trigger_config: original.trigger_config,
is_active: false,
})
.select()
.single()
if (copyErr || !copy) {
return NextResponse.json({ error: copyErr?.message ?? 'copy failed' }, { status: 500 })
}
const { data: steps } = await admin
.from('automation_steps')
.select('id, parent_step_id, branch, step_type, step_config, position')
.eq('automation_id', id)
.order('position', { ascending: true })
if (steps && steps.length > 0) {
// Re-map parent_step_id: build old→new id map first so the second
// pass inserts rows with correct parent references.
const idMap = new Map<string, string>()
const uid = () =>
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
: Math.random().toString(36).slice(2) + Date.now().toString(36)
for (const row of steps) idMap.set(row.id as string, uid())
const rows = steps.map((row) => ({
id: idMap.get(row.id as string)!,
automation_id: copy.id,
parent_step_id: row.parent_step_id ? idMap.get(row.parent_step_id as string) : null,
branch: row.branch,
step_type: row.step_type,
step_config: row.step_config,
position: row.position,
}))
const { error: insErr } = await admin.from('automation_steps').insert(rows)
if (insErr) return NextResponse.json({ error: insErr.message }, { status: 500 })
}
return NextResponse.json({ automation: copy }, { status: 201 })
}