feat(creatives): task 2 - integrations auth and visibility scope with sanitization

This commit is contained in:
Marcos Fargas
2026-10-12 13:13:46 +02:00
parent f08bcf2bb8
commit 62d1f3a5f6
4 changed files with 326 additions and 1 deletions

View File

@@ -0,0 +1,117 @@
import { describe, it } from 'node:test';
import assert from 'node:assert';
import { sanitizeForRole, FORBIDDEN_CLIENT_FIELDS, applyVisibilityScope } from './creatives-visibility.ts';
describe('Creatives Visibility Scope & Serializer', () => {
const sampleCreative = {
id: '11111111-1111-1111-1111-111111111111',
tenant_id: '8bec977a-6032-4759-aa7f-b5042081f450',
campaign_name: 'Campaña Matrículas',
format: 'estatico',
angle: 'prueba_social',
hook: 'Aprende inglés en 3 meses',
primary_text: 'El método que funciona para profesionales.',
headline: 'Clases online con nativos',
cta: 'Saber más',
asset_url: 'https://cdn.ejemplo.com/ad1.jpg',
thumbnail_url: 'https://cdn.ejemplo.com/thumb1.jpg',
status: 'publicada',
visibility: 'client',
parent_id: '00000000-0000-0000-0000-000000000000',
generation_request_id: '22222222-2222-2222-2222-222222222222',
created_by: 'claude',
created_at: '2026-10-12T10:00:00Z',
updated_at: '2026-10-12T10:00:00Z',
};
const sampleMetrics = {
id: '33333333-3333-3333-3333-333333333333',
tenant_id: '8bec977a-6032-4759-aa7f-b5042081f450',
creative_id: '11111111-1111-1111-1111-111111111111',
meta_ad_id: 'meta_123',
date: '2026-10-12',
impressions: 5000,
clicks: 120,
spend: 45.5,
raw: { meta_response_debug: true, internal_details: 'sensitive' },
};
const sampleGenerationRequest = {
id: '44444444-4444-4444-4444-444444444444',
tenant_id: '8bec977a-6032-4759-aa7f-b5042081f450',
reason: 'fatiga',
brief: { client_slug: 'bps', variants_requested: 2 },
cost_estimate: { tokens: 12000, credits: 1 },
};
it('Agency role receives all fields unmodified', () => {
const res = sanitizeForRole(sampleCreative, 'agency');
assert.strictEqual(res.angle, 'prueba_social');
assert.strictEqual(res.created_by, 'claude');
assert.strictEqual(res.parent_id, '00000000-0000-0000-0000-000000000000');
assert.strictEqual(res.generation_request_id, '22222222-2222-2222-2222-222222222222');
const metricsRes = sanitizeForRole(sampleMetrics, 'admin');
assert.ok(metricsRes.raw);
});
it('Client role NEVER receives forbidden fields in creatives', () => {
const res = sanitizeForRole(sampleCreative, 'client');
for (const field of FORBIDDEN_CLIENT_FIELDS) {
assert.strictEqual(
(res as any)[field],
undefined,
`Field "${field}" must not be present for client`
);
}
// Permitted fields remain
assert.strictEqual(res.headline, 'Clases online con nativos');
assert.strictEqual(res.hook, 'Aprende inglés en 3 meses');
assert.strictEqual(res.primary_text, 'El método que funciona para profesionales.');
assert.strictEqual(res.status, 'publicada');
});
it('Client role NEVER receives raw in metrics', () => {
const res = sanitizeForRole(sampleMetrics, 'client');
assert.strictEqual(res.raw, undefined);
assert.strictEqual(res.impressions, 5000);
assert.strictEqual(res.clicks, 120);
});
it('Client role NEVER receives brief or cost_estimate in generation requests', () => {
const res = sanitizeForRole(sampleGenerationRequest, 'client');
assert.strictEqual((res as any).brief, undefined);
assert.strictEqual((res as any).cost_estimate, undefined);
assert.strictEqual(res.reason, 'fatiga');
});
it('Sanitizes arrays of objects properly for client role', () => {
const list = [sampleCreative, sampleCreative];
const sanitizedList = sanitizeForRole(list, 'client');
assert.strictEqual(sanitizedList.length, 2);
sanitizedList.forEach((item: any) => {
assert.strictEqual(item.angle, undefined);
assert.strictEqual(item.created_by, undefined);
assert.strictEqual(item.hook, 'Aprende inglés en 3 meses');
});
});
it('Query builder applies strict client filters for creatives', () => {
const mockQuery = {
filters: [] as string[],
eq(k: string, v: any) {
this.filters.push(`${k}=${v}`);
return this;
},
in(k: string, values: any[]) {
this.filters.push(`${k} IN (${values.join(',')})`);
return this;
},
};
applyVisibilityScope(mockQuery, 'creatives', 'client', '8bec977a-6032-4759-aa7f-b5042081f450');
assert.ok(mockQuery.filters.includes('tenant_id=8bec977a-6032-4759-aa7f-b5042081f450'));
assert.ok(mockQuery.filters.includes('visibility=client'));
assert.ok(mockQuery.filters.some((f) => f.startsWith('status IN')));
});
});

View File

@@ -0,0 +1,102 @@
import { isAgencyRole } from './agency.ts';
/**
* Campos estrictamente prohibidos para el rol 'client'
* Según Sección 4 del brief:
* "Campos que el cliente nunca recibe en ninguna respuesta de API:
* angle, generation_request_id, parent_id, raw, cost_estimate, brief, created_by."
*/
export const FORBIDDEN_CLIENT_FIELDS = [
'angle',
'generation_request_id',
'parent_id',
'raw',
'cost_estimate',
'brief',
'created_by',
] as const;
export type UserRoleType = 'admin' | 'team' | 'agency' | 'client';
/**
* Normaliza el rol del usuario a 'agency' o 'client'
*/
export function normalizeRole(role: string | null | undefined): 'agency' | 'client' {
if (role === 'agency' || isAgencyRole(role)) return 'agency';
return 'client';
}
/**
* Aplica los filtros de visibilidad de la BD de acuerdo al rol y al tenant_id
*/
export function applyVisibilityScope<T extends Record<string, any>>(
query: any,
resource: 'creatives' | 'creative_metrics_daily' | 'approvals' | 'creative_events',
role: string | null | undefined,
tenantId?: string | null
) {
const normRole = normalizeRole(role);
if (tenantId) {
query = query.eq('tenant_id', tenantId);
}
if (normRole === 'agency') {
// La agencia ve todo dentro del tenant (o global si no hay tenantId)
return query;
}
// Rol CLIENT: reglas estrictas de la sección 4
switch (resource) {
case 'creatives':
// Solo status in ('pendiente_aprobacion','aprobada','publicada','retirada') Y visibility = 'client'
query = query
.eq('visibility', 'client')
.in('status', ['pendiente_aprobacion', 'aprobada', 'publicada', 'retirada']);
break;
case 'creative_events':
// Solo visibility = 'client'
query = query.eq('visibility', 'client');
break;
case 'approvals':
// El cliente ve aprobaciones relacionadas con creatividades visibles
// (al consultar con join a creatives visibles o filtro directo)
break;
case 'creative_metrics_daily':
// En metrics_daily el serializador elimina 'raw' y la query solo debe incluir creatividades visibles
break;
}
return query;
}
/**
* Serializador que garantiza que ningún campo prohibido llegue al cliente.
* Opera recursivamente sobre objetos o arrays.
*/
export function sanitizeForRole<T>(data: T, role: string | null | undefined): T {
const normRole = normalizeRole(role);
if (normRole === 'agency') {
return data;
}
if (Array.isArray(data)) {
return data.map((item) => sanitizeForRole(item, role)) as unknown as T;
}
if (data !== null && typeof data === 'object') {
const sanitized: Record<string, any> = {};
for (const [key, value] of Object.entries(data)) {
if ((FORBIDDEN_CLIENT_FIELDS as readonly string[]).includes(key)) {
continue; // omitir campo prohibido
}
sanitized[key] = sanitizeForRole(value, role);
}
return sanitized as T;
}
return data;
}

View File

@@ -0,0 +1,105 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@supabase/supabase-js';
export type IntegrationCaller = 'n8n' | 'claude';
export interface IntegrationAuthResult {
ok: boolean;
caller?: IntegrationCaller;
tenantId?: string;
errorResponse?: NextResponse;
}
/**
* Valida la autenticación de llamadas a /api/integrations/*
* Revisa X-Api-Key contra N8N_API_KEY y CLAUDE_API_KEY.
* Valida que el tenant (obtenido por tenant_id o client_id en query/body) tenga creatives_enabled = true.
*/
export async function authenticateIntegrationRequest(
req: NextRequest,
options: { requireTenant?: boolean } = { requireTenant: true }
): Promise<IntegrationAuthResult> {
const apiKey = req.headers.get('x-api-key') || req.headers.get('X-Api-Key');
const n8nKey = process.env.N8N_API_KEY?.trim();
const claudeKey = process.env.CLAUDE_API_KEY?.trim();
let caller: IntegrationCaller | undefined;
if (apiKey && n8nKey && apiKey === n8nKey) {
caller = 'n8n';
} else if (apiKey && claudeKey && apiKey === claudeKey) {
caller = 'claude';
}
if (!caller) {
return {
ok: false,
errorResponse: NextResponse.json(
{ error: 'Unauthorized: clave de integración inválida o no proporcionada' },
{ status: 401 }
),
};
}
if (!options.requireTenant) {
return { ok: true, caller };
}
// Extraer tenant_id o client_id de query params o body
let tenantId =
req.nextUrl.searchParams.get('tenant_id') ||
req.nextUrl.searchParams.get('client_id');
if (!tenantId && req.method !== 'GET') {
try {
const cloned = req.clone();
const body = await cloned.json();
tenantId = body.tenant_id || body.client_id;
} catch {
// Ignorar error al parsear body si no viene JSON
}
}
if (!tenantId) {
return {
ok: false,
errorResponse: NextResponse.json(
{ error: 'Bad Request: client_id o tenant_id requerido' },
{ status: 400 }
),
};
}
// Verificar en base de datos si tiene creatives_enabled
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const adminClient = createClient(supabaseUrl, serviceRoleKey);
const { data: tenant, error } = await adminClient
.from('tenants')
.select('id, creatives_enabled')
.eq('id', tenantId)
.maybeSingle();
if (error || !tenant) {
return {
ok: false,
errorResponse: NextResponse.json(
{ error: 'Not Found: cliente no encontrado' },
{ status: 404 }
),
};
}
if (!tenant.creatives_enabled) {
return {
ok: false,
errorResponse: NextResponse.json(
{ error: 'Forbidden: el cliente no tiene habilitado el módulo de creatividades' },
{ status: 403 }
),
};
}
return { ok: true, caller, tenantId: tenant.id };
}

View File

@@ -59,12 +59,13 @@ export async function middleware(req: NextRequest) {
const url = req.nextUrl.clone();
const path = url.pathname;
// Public: login, attribution script, health, inbound webhooks (CRM), cron (Bearer CRON_SECRET)
// Public: login, attribution script, health, inbound webhooks (CRM), cron (Bearer CRON_SECRET), integrations (API Key)
const isPublic =
path === '/login' ||
path === '/api/health' ||
path.startsWith('/api/webhooks/') ||
path.startsWith('/api/cron/') ||
path.startsWith('/api/integrations/') ||
path === '/_4v_attr.js' ||
path.startsWith('/_next') ||
path === '/favicon.ico' ||