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

@@ -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():

View File

@@ -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;

View File

@@ -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) {