From e3fe157fa5866205d73088a67fa22cba3594c68d Mon Sep 17 00:00:00 2001 From: 4Ventures <4ventures@MacBook-Air-de-4Ventures.local> Date: Wed, 7 Oct 2026 15:57:21 +0200 Subject: [PATCH] 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 --- scripts/dokploy_apply_documents_rls.py | 95 ++++++++++++++++++++++++++ sql/documents_rls.sql | 44 ++++++++++++ src/app/api/chat/route.ts | 21 ++++-- src/app/api/creative-pipeline/route.ts | 1 + src/app/api/creatives/route.ts | 94 +++++++++++++++++-------- src/app/dashboard/calendario/page.tsx | 1 + src/app/dashboard/kpis/kpis/route.ts | 38 +++++++---- src/lib/agency.ts | 25 ++++--- src/lib/meta-publish.ts | 50 ++++++++++++++ src/lib/tenant-context.ts | 36 +++++++++- 10 files changed, 347 insertions(+), 58 deletions(-) create mode 100644 scripts/dokploy_apply_documents_rls.py create mode 100644 sql/documents_rls.sql diff --git a/scripts/dokploy_apply_documents_rls.py b/scripts/dokploy_apply_documents_rls.py new file mode 100644 index 0000000..e955795 --- /dev/null +++ b/scripts/dokploy_apply_documents_rls.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""Apply sql/documents_rls.sql via Dokploy supabase-db terminal.""" +from __future__ import annotations + +import base64 +import os +import re +import sys +from pathlib import Path + +from playwright.sync_api import sync_playwright + +ROOT = Path("/Users/4ventures/4ventures-client-portal") +BPS = Path("/Users/4ventures/Projects/bps-facturacion") +SQL = ROOT / "sql" / "documents_rls.sql" +BASE = "https://dokploy.4ventures.es" + + +def env(key: str) -> str: + v = os.environ.get(key, "").strip() + if v: + return v + p = BPS / ".env" + if not p.is_file(): + return "" + for line in p.read_text().splitlines(): + if line.startswith(f"{key}="): + return line.split("=", 1)[1].strip().strip('"').strip("'") + return "" + + +def main() -> int: + email = env("DOKPLOY_EMAIL") or "marketing@4ventures.es" + password = env("DOKPLOY_PASSWORD") + if not password: + print("missing DOKPLOY_PASSWORD", flush=True) + return 1 + b64 = base64.b64encode(SQL.read_text(encoding="utf-8").encode()).decode() + chunks = [b64[i : i + 180] for i in range(0, len(b64), 180)] + + with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + page = browser.new_page(viewport={"width": 1400, "height": 1000}) + page.goto(BASE + "/", wait_until="domcontentloaded", timeout=90000) + page.wait_for_timeout(800) + if "/dashboard" not in page.url: + page.locator('input[type="email"], input[name="email"]').first.fill(email) + page.locator('input[type="password"]').first.fill(password) + page.locator('button[type="submit"]').first.click() + page.wait_for_url(re.compile(r"/dashboard"), timeout=90000) + print("login ok", flush=True) + page.goto(f"{BASE}/dashboard/docker", wait_until="domcontentloaded", timeout=120000) + page.wait_for_timeout(800) + page.locator("input[placeholder*='ilter' i], input[placeholder*='earch' i]").first.fill( + "supabase-db" + ) + page.wait_for_timeout(1000) + page.keyboard.press("Escape") + page.wait_for_timeout(200) + page.get_by_role("button", name=re.compile(r"Open menu", re.I)).first.click() + page.wait_for_timeout(400) + page.get_by_text("Terminal", exact=True).click() + page.wait_for_timeout(1000) + if page.locator("button", has_text="Bash").count(): + page.locator("button", has_text="Bash").first.click() + page.wait_for_timeout(2800) + if page.locator("textarea.xterm-helper-textarea").count(): + page.locator("textarea.xterm-helper-textarea").first.click() + + def run(cmd: str, wait: int = 800) -> None: + page.keyboard.type(cmd, delay=1) + page.keyboard.press("Enter") + page.wait_for_timeout(wait) + + run("rm -f /tmp/cpl.b64 /tmp/cpl.sql; echo READY", 1200) + for i, ch in enumerate(chunks): + op = ">" if i == 0 else ">>" + run(f"printf '%s' '{ch}' {op} /tmp/cpl.b64", 400) + run( + "base64 -d /tmp/cpl.b64 > /tmp/cpl.sql && " + "psql -U postgres -d postgres -v ON_ERROR_STOP=1 -f /tmp/cpl.sql; echo EXIT:$?", + 12000, + ) + text = page.evaluate( + "() => (document.querySelector('.xterm-rows')||{}).innerText||''" + ) + browser.close() + ok = "EXIT:0" in text + print("SQL_OK", ok, flush=True) + print(text[-500:], flush=True) + return 0 if ok else 6 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/sql/documents_rls.sql b/sql/documents_rls.sql new file mode 100644 index 0000000..426e46f --- /dev/null +++ b/sql/documents_rls.sql @@ -0,0 +1,44 @@ +-- Tenant isolation for editorial calendars and other documents. +-- Clients only see their own tenant_id; agency uses service role / viaView APIs. + +alter table public.documents enable row level security; + +drop policy if exists documents_tenant_select on public.documents; +drop policy if exists documents_tenant_insert on public.documents; +drop policy if exists documents_tenant_update on public.documents; +drop policy if exists documents_tenant_delete on public.documents; + +create policy documents_tenant_select on public.documents + for select using ( + tenant_id::text = coalesce( + auth.jwt() -> 'app_metadata' ->> 'tenant_id', + auth.jwt() -> 'user_metadata' ->> 'tenant_id' + ) + ); + +create policy documents_tenant_insert on public.documents + for insert with check ( + tenant_id::text = coalesce( + auth.jwt() -> 'app_metadata' ->> 'tenant_id', + auth.jwt() -> 'user_metadata' ->> 'tenant_id' + ) + ); + +create policy documents_tenant_update on public.documents + for update using ( + tenant_id::text = coalesce( + auth.jwt() -> 'app_metadata' ->> 'tenant_id', + auth.jwt() -> 'user_metadata' ->> 'tenant_id' + ) + ); + +create policy documents_tenant_delete on public.documents + for delete using ( + tenant_id::text = coalesce( + auth.jwt() -> 'app_metadata' ->> 'tenant_id', + auth.jwt() -> 'user_metadata' ->> 'tenant_id' + ) + ); + +comment on table public.documents is + 'Tenant-scoped docs (type=calendar RRSS plans, etc.). RLS: JWT tenant only; agency writes via service role.'; diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index b7ad457..081c0c3 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -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) { diff --git a/src/app/api/creative-pipeline/route.ts b/src/app/api/creative-pipeline/route.ts index d70014e..c410f01 100644 --- a/src/app/api/creative-pipeline/route.ts +++ b/src/app/api/creative-pipeline/route.ts @@ -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); diff --git a/src/app/api/creatives/route.ts b/src/app/api/creatives/route.ts index 4d38652..7328aeb 100644 --- a/src/app/api/creatives/route.ts +++ b/src/app/api/creatives/route.ts @@ -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 = { in_review: ['approved', 'rejected'], }; -async function resolveTenantId(user: { - app_metadata?: Record; - user_metadata?: Record; -}): 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 }); } diff --git a/src/app/dashboard/calendario/page.tsx b/src/app/dashboard/calendario/page.tsx index 00fd2f1..48fb102 100644 --- a/src/app/dashboard/calendario/page.tsx +++ b/src/app/dashboard/calendario/page.tsx @@ -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[]) || []; diff --git a/src/app/dashboard/kpis/kpis/route.ts b/src/app/dashboard/kpis/kpis/route.ts index 02d6780..30bd676 100644 --- a/src/app/dashboard/kpis/kpis/route.ts +++ b/src/app/dashboard/kpis/kpis/route.ts @@ -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'); diff --git a/src/lib/agency.ts b/src/lib/agency.ts index 04688e5..878d173 100644 --- a/src/lib/agency.ts +++ b/src/lib/agency.ts @@ -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; user_metadata?: Record; } | 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; user_metadata?: Record; } | 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(): diff --git a/src/lib/meta-publish.ts b/src/lib/meta-publish.ts index e8081b2..af82785 100644 --- a/src/lib/meta-publish.ts +++ b/src/lib/meta-publish.ts @@ -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; diff --git a/src/lib/tenant-context.ts b/src/lib/tenant-context.ts index eeb5420..9d7868c 100644 --- a/src/lib/tenant-context.ts +++ b/src/lib/tenant-context.ts @@ -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; user_metadata?: Record; @@ -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) {