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:
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -121,6 +121,7 @@ export default async function CalendarioPage({
|
||||
.select(
|
||||
'id,external_ref,title,format,month,status,cover_path,slide_paths,caption,notes,created_at,scheduled_at,channels,publish_status'
|
||||
)
|
||||
.eq('tenant_id', ctx.tenantId)
|
||||
.order('created_at', { ascending: true })
|
||||
.limit(2000);
|
||||
pipe = (data as PipelineCalendarRow[]) || [];
|
||||
|
||||
@@ -13,24 +13,38 @@ export async function GET() {
|
||||
|
||||
const ctx = await resolveActiveTenant(user);
|
||||
const tenant_id = ctx.tenantId;
|
||||
// Never dump all tenants' ads/pipeline/integrations into one HTML payload.
|
||||
if (!tenant_id || ctx.portfolioWide) {
|
||||
return new NextResponse(
|
||||
'Selecciona un cliente en Contexto para ver KPIs.',
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const adminSupabase = createSupabaseClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!);
|
||||
|
||||
let queryAd = adminSupabase.from('ad_daily').select('*').order('date', { ascending: true });
|
||||
if (tenant_id) queryAd = queryAd.eq('tenant_id', tenant_id);
|
||||
const { data: adDaily } = await queryAd;
|
||||
const { data: adDaily } = await adminSupabase
|
||||
.from('ad_daily')
|
||||
.select('*')
|
||||
.eq('tenant_id', tenant_id)
|
||||
.order('date', { ascending: true });
|
||||
|
||||
let queryOrg = adminSupabase.from('organic_daily').select('*').order('day', { ascending: true });
|
||||
if (tenant_id) queryOrg = queryOrg.eq('tenant_id', tenant_id);
|
||||
const { data: orgDaily } = await queryOrg;
|
||||
const { data: orgDaily } = await adminSupabase
|
||||
.from('organic_daily')
|
||||
.select('*')
|
||||
.eq('tenant_id', tenant_id)
|
||||
.order('day', { ascending: true });
|
||||
|
||||
let queryPipe = adminSupabase.from('creative_pipeline').select('*').order('created_at', { ascending: true });
|
||||
if (tenant_id) queryPipe = queryPipe.eq('tenant_id', tenant_id);
|
||||
const { data: creativePipeline } = await queryPipe;
|
||||
const { data: creativePipeline } = await adminSupabase
|
||||
.from('creative_pipeline')
|
||||
.select('*')
|
||||
.eq('tenant_id', tenant_id)
|
||||
.order('created_at', { ascending: true });
|
||||
|
||||
let queryIntegrations = adminSupabase.from('integrations').select('*');
|
||||
if (tenant_id) queryIntegrations = queryIntegrations.eq('tenant_id', tenant_id);
|
||||
const { data: integrations } = await queryIntegrations;
|
||||
const { data: integrations } = await adminSupabase
|
||||
.from('integrations')
|
||||
.select('*')
|
||||
.eq('tenant_id', tenant_id);
|
||||
|
||||
const igIntegration = integrations?.find((i: any) => i.provider === 'instagram');
|
||||
const fbIntegration = integrations?.find((i: any) => i.provider === 'facebook');
|
||||
|
||||
@@ -34,26 +34,31 @@ export function isAgencyRole(role: string | undefined | null): boolean {
|
||||
return role === 'admin' || role === 'team';
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorization role. Privileged roles (admin/team) ONLY from app_metadata
|
||||
* so clients cannot self-elevate via user_metadata.updateUser.
|
||||
*/
|
||||
export function userRole(user: {
|
||||
app_metadata?: Record<string, unknown>;
|
||||
user_metadata?: Record<string, unknown>;
|
||||
} | null): string {
|
||||
return (
|
||||
(user?.app_metadata?.role as string) ||
|
||||
(user?.user_metadata?.role as string) ||
|
||||
'client'
|
||||
);
|
||||
const app = (user?.app_metadata?.role as string | undefined)?.trim();
|
||||
if (app === 'admin' || app === 'team' || app === 'client') return app;
|
||||
// Legacy clients may only have user_metadata.role=client — never admin/team.
|
||||
const um = (user?.user_metadata?.role as string | undefined)?.trim();
|
||||
if (um === 'client') return 'client';
|
||||
return 'client';
|
||||
}
|
||||
|
||||
export function homeTenantId(user: {
|
||||
app_metadata?: Record<string, unknown>;
|
||||
user_metadata?: Record<string, unknown>;
|
||||
} | null): string | null {
|
||||
return (
|
||||
(user?.app_metadata?.tenant_id as string) ||
|
||||
(user?.user_metadata?.tenant_id as string) ||
|
||||
null
|
||||
);
|
||||
const app = (user?.app_metadata?.tenant_id as string | undefined)?.trim();
|
||||
if (app) return app;
|
||||
// Legacy client JWT may still carry tenant only in user_metadata.
|
||||
const um = (user?.user_metadata?.tenant_id as string | undefined)?.trim();
|
||||
return um || null;
|
||||
}
|
||||
|
||||
export function serviceHeaders():
|
||||
|
||||
@@ -205,6 +205,46 @@ async function loadIntegrations(tenantId: string): Promise<{
|
||||
return { pageId, igUserId };
|
||||
}
|
||||
|
||||
/** Block publish if FB Page / IG user id is also stored on another tenant. */
|
||||
async function assertMetaAccountsExclusive(
|
||||
tenantId: string,
|
||||
pageId: string | null,
|
||||
igUserId: string | null
|
||||
): Promise<{ ok: boolean; error?: string }> {
|
||||
const ids = [pageId, igUserId].filter(
|
||||
(x): x is string => Boolean(x && x.trim())
|
||||
);
|
||||
if (!ids.length) return { ok: true };
|
||||
const headers = serviceHeaders();
|
||||
const base = supabaseUrl();
|
||||
if (!headers || !base) return { ok: true };
|
||||
const or = ids
|
||||
.map((id) => `external_account_id.eq.${encodeURIComponent(id)}`)
|
||||
.join(',');
|
||||
const r = await fetch(
|
||||
`${base}/rest/v1/integrations?or=(${or})&provider=in.(facebook,instagram)&status=eq.connected&select=tenant_id,provider,external_account_id`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
if (!r.ok) return { ok: true };
|
||||
const rows = (await r.json()) as {
|
||||
tenant_id: string;
|
||||
provider: string;
|
||||
external_account_id?: string | null;
|
||||
}[];
|
||||
const foreign = rows.filter((row) => row.tenant_id !== tenantId);
|
||||
if (!foreign.length) return { ok: true };
|
||||
const detail = foreign
|
||||
.map(
|
||||
(f) =>
|
||||
`${f.provider}:${f.external_account_id || '?'}→tenant ${f.tenant_id.slice(0, 8)}`
|
||||
)
|
||||
.join(', ');
|
||||
return {
|
||||
ok: false,
|
||||
error: `Cuenta Meta también vinculada a otro cliente (${detail}). Corrige Integraciones antes de publicar.`,
|
||||
};
|
||||
}
|
||||
|
||||
/** Find Facebook Page that owns an IG business account. */
|
||||
async function findPageForIg(
|
||||
token: string,
|
||||
@@ -249,6 +289,16 @@ export async function resolveTenantMetaTargets(
|
||||
};
|
||||
}
|
||||
|
||||
// Refuse if the same Page/IG id is linked to another tenant (cross-client leak).
|
||||
const exclusive = await assertMetaAccountsExclusive(
|
||||
tenantId,
|
||||
integPage,
|
||||
igUserId
|
||||
);
|
||||
if (!exclusive.ok) {
|
||||
return { targets: null, error: exclusive.error };
|
||||
}
|
||||
|
||||
let pageId = integPage;
|
||||
let pageToken: string | null = null;
|
||||
let working: string | null = null;
|
||||
|
||||
@@ -18,6 +18,28 @@ export type ResolvedTenant = {
|
||||
label: string;
|
||||
};
|
||||
|
||||
const UUID_RE =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
/** Re-validate view-as cookie against tenants table (defense vs forged cookie). */
|
||||
async function lookupTenant(
|
||||
tenantId: string
|
||||
): Promise<{ id: string; name: string } | null> {
|
||||
if (!UUID_RE.test(tenantId)) return null;
|
||||
const headers = serviceHeaders();
|
||||
const base = supabaseUrl();
|
||||
if (!headers || !base) return null;
|
||||
const r = await fetch(
|
||||
`${base}/rest/v1/tenants?id=eq.${encodeURIComponent(
|
||||
tenantId
|
||||
)}&select=id,name&limit=1`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
if (!r.ok) return null;
|
||||
const rows = (await r.json()) as { id: string; name?: string }[];
|
||||
return rows[0] ? { id: rows[0].id, name: rows[0].name || 'Cliente' } : null;
|
||||
}
|
||||
|
||||
export async function resolveActiveTenant(user: {
|
||||
app_metadata?: Record<string, unknown>;
|
||||
user_metadata?: Record<string, unknown>;
|
||||
@@ -28,11 +50,21 @@ export async function resolveActiveTenant(user: {
|
||||
const viewName = cookieStore.get(VIEW_TENANT_NAME_COOKIE)?.value || null;
|
||||
|
||||
if (isAgencyRole(role) && viewId) {
|
||||
const tenant = await lookupTenant(viewId);
|
||||
if (!tenant) {
|
||||
// Forged / deleted tenant cookie → fall back to portfolio (safe).
|
||||
return {
|
||||
tenantId: null,
|
||||
viaView: false,
|
||||
portfolioWide: true,
|
||||
label: 'Portafolio (todos los clientes)',
|
||||
};
|
||||
}
|
||||
return {
|
||||
tenantId: viewId,
|
||||
tenantId: tenant.id,
|
||||
viaView: true,
|
||||
portfolioWide: false,
|
||||
label: viewName || 'Cliente',
|
||||
label: viewName || tenant.name || 'Cliente',
|
||||
};
|
||||
}
|
||||
if (isAgencyRole(role) && !viewId) {
|
||||
|
||||
Reference in New Issue
Block a user