Harden per-client isolation for calendars, Meta, creatives, and KPIs.

Validate view-as tenants, scope service-role mutations by tenant_id, add documents RLS, refuse portfolio-wide data dumps, and block Meta publish when a Page/IG is linked to another client.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-10-07 15:57:21 +02:00
parent 83de3fb3aa
commit e3fe157fa5
10 changed files with 347 additions and 58 deletions

View File

@@ -21,8 +21,8 @@ export async function POST(req: NextRequest) {
const ctx = await resolveActiveTenant(user);
const agency = isAgencyRole(userRole(user));
const tenantId = ctx.tenantId ||
user.app_metadata?.tenant_id || user.user_metadata?.tenant_id || null;
// Never fall back to agency home tenant while "vista global" — that mixes clients.
const tenantId = ctx.tenantId;
const companyName = ctx.label || user.user_metadata?.company || 'Cliente';
if (!tenantId && !ctx.portfolioWide) {
return NextResponse.json(
@@ -30,7 +30,6 @@ export async function POST(req: NextRequest) {
{ status: 403 }
);
}
// 1. Extraer mensaje del usuario
let userMessage = '';
try {
@@ -92,6 +91,11 @@ export async function POST(req: NextRequest) {
normQuery.includes('instrucciones');
if (isHelpQuery) {
if (agency && ctx.portfolioWide) {
return reply(
'Selecciona un **cliente en Contexto** para usar el asistente con los datos de esa cuenta. En vista global no mezclo información entre clientes.'
);
}
return reply(
`👋 **¡Hola! Soy tu Asistente Financiero & de Crecimiento de 4Ventures** para **${companyName}**.\n\n` +
`Estoy conectado directamente a tu base de datos analítica y ejecuto cálculos matemáticos deterministas (temperatura 0.0, sin alucinaciones). Aquí tienes todo lo que puedes preguntarme:\n\n` +
@@ -121,6 +125,12 @@ export async function POST(req: NextRequest) {
);
}
if (ctx.portfolioWide || !tenantId) {
return reply(
'Selecciona un **cliente en Contexto** para consultar sus datos (calendario, redes, KPIs). En vista global no mezclo información entre cuentas.'
);
}
// 4. Intención: Simulación Determinista "¿Qué pasa si...?"
if (
normQuery.includes('simul') ||
@@ -226,7 +236,10 @@ export async function POST(req: NextRequest) {
const res = await fetch(`${supabaseUrl}/rest/v1/rpc/targets_vs_actual`, {
method: 'POST',
headers,
body: JSON.stringify({ p_month: currentMonth })
body: JSON.stringify({
p_month: currentMonth,
p_tenant_id: tenantId,
}),
});
if (res.ok) {

View File

@@ -103,6 +103,7 @@ export async function GET(req: NextRequest) {
let query = supabase
.from('creative_pipeline')
.select(SELECT)
.eq('tenant_id', ctx.tenantId)
.order('month', { ascending: true })
.order('created_at', { ascending: true })
.limit(1000);

View File

@@ -1,14 +1,12 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { createClient } from '@/lib/supabase/server';
import {
VIEW_TENANT_COOKIE,
homeTenantId,
isAgencyRole,
serviceHeaders,
supabaseUrl,
userRole,
} from '@/lib/agency';
import { resolveActiveTenant } from '@/lib/tenant-context';
const SELECT =
'id,title,status,channel,placement,format,headline,primary_text,description,cta,media_url,brand_name,landing_url,storage_path,mime_type,bytes,meta,created_at,updated_at';
@@ -51,19 +49,6 @@ const CLIENT_ALLOWED_TRANSITIONS: Record<string, string[]> = {
in_review: ['approved', 'rejected'],
};
async function resolveTenantId(user: {
app_metadata?: Record<string, unknown>;
user_metadata?: Record<string, unknown>;
}): Promise<{ tenantId: string | null; viaView: boolean }> {
const role = userRole(user);
const cookieStore = await cookies();
const viewId = cookieStore.get(VIEW_TENANT_COOKIE)?.value || null;
if (isAgencyRole(role) && viewId) {
return { tenantId: viewId, viaView: true };
}
return { tenantId: homeTenantId(user), viaView: false };
}
function str(v: unknown, max = 2000): string | null {
if (typeof v !== 'string') return null;
const t = v.trim();
@@ -114,7 +99,9 @@ export async function GET() {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { tenantId, viaView } = await resolveTenantId(user);
const ctx = await resolveActiveTenant(user);
const tenantId = ctx.tenantId;
const viaView = ctx.viaView;
if (viaView && tenantId) {
const headers = serviceHeaders();
@@ -194,9 +181,13 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { tenantId, viaView } = await resolveTenantId(user);
if (!tenantId) {
return NextResponse.json({ error: 'Usuario sin tenant_id' }, { status: 403 });
const ctx = await resolveActiveTenant(user);
const tenantId = ctx.tenantId;
if (!tenantId || ctx.portfolioWide) {
return NextResponse.json(
{ error: 'Selecciona un cliente en Contexto.' },
{ status: 400 }
);
}
if (!isAgencyRole(userRole(user))) {
return NextResponse.json(
@@ -224,7 +215,7 @@ export async function POST(req: NextRequest) {
},
};
if (viaView) {
if (ctx.viaView) {
const headers = serviceHeaders();
if (!headers) {
return NextResponse.json(
@@ -321,7 +312,14 @@ export async function PATCH(req: NextRequest) {
const agency = isAgencyRole(userRole(user));
const headers = serviceHeaders();
const { tenantId } = await resolveTenantId(user);
const ctx = await resolveActiveTenant(user);
const tenantId = ctx.tenantId;
if (!tenantId || (agency && ctx.portfolioWide)) {
return NextResponse.json(
{ error: 'Selecciona un cliente en Contexto.' },
{ status: 400 }
);
}
if (!agency) {
const current = await fetchCurrentStatus(id, tenantId, headers);
@@ -376,7 +374,9 @@ export async function PATCH(req: NextRequest) {
if (agency && headers) {
const r = await fetch(
`${supabaseUrl()}/rest/v1/creatives?id=eq.${encodeURIComponent(id)}`,
`${supabaseUrl()}/rest/v1/creatives?id=eq.${encodeURIComponent(
id
)}&tenant_id=eq.${encodeURIComponent(tenantId)}`,
{
method: 'PATCH',
headers: {
@@ -391,13 +391,21 @@ export async function PATCH(req: NextRequest) {
return NextResponse.json({ error: await r.text() }, { status: 500 });
}
const data = await r.json();
return NextResponse.json({ creative: data[0] || data });
const row = Array.isArray(data) ? data[0] : data;
if (!row) {
return NextResponse.json(
{ error: 'Creatividad no encontrada en este cliente' },
{ status: 404 }
);
}
return NextResponse.json({ creative: row });
}
const { data, error } = await supabase
.from('creatives')
.update(clean)
.eq('id', id)
.eq('tenant_id', tenantId)
.select(SELECT)
.single();
if (error) {
@@ -427,20 +435,46 @@ export async function DELETE(req: NextRequest) {
{ status: 403 }
);
}
const ctx = await resolveActiveTenant(user);
if (!ctx.tenantId || ctx.portfolioWide) {
return NextResponse.json(
{ error: 'Selecciona un cliente en Contexto.' },
{ status: 400 }
);
}
const headers = serviceHeaders();
if (agency && headers) {
const r = await fetch(
`${supabaseUrl()}/rest/v1/creatives?id=eq.${encodeURIComponent(id)}`,
{ method: 'DELETE', headers }
const del = await fetch(
`${supabaseUrl()}/rest/v1/creatives?id=eq.${encodeURIComponent(
id
)}&tenant_id=eq.${encodeURIComponent(ctx.tenantId)}`,
{
method: 'DELETE',
headers: {
...headers,
Prefer: 'return=representation',
},
}
);
if (!r.ok) {
return NextResponse.json({ error: await r.text() }, { status: 500 });
if (!del.ok) {
return NextResponse.json({ error: await del.text() }, { status: 500 });
}
const rows = await del.json().catch(() => []);
if (Array.isArray(rows) && rows.length === 0) {
return NextResponse.json(
{ error: 'Creatividad no encontrada en este cliente' },
{ status: 404 }
);
}
return NextResponse.json({ ok: true });
}
const { error } = await supabase.from('creatives').delete().eq('id', id);
const { error } = await supabase
.from('creatives')
.delete()
.eq('id', id)
.eq('tenant_id', ctx.tenantId);
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}