SaaS multi-tenant seguro con Claude Code: RLS, autenticacion, facturacion y logs
Implementa SaaS multi-tenant con Claude Code: tenant_id, RLS, auth, limites de plan, jobs, logs y pruebas.
Un SaaS multi-tenant es una aplicacion que sirve a varias organizaciones de clientes desde una misma base de codigo. Un tenant es el limite de datos del cliente: empresa, workspace, portal o cuenta. Claude Code puede modificar rutas, helpers de base de datos, jobs y pantallas con mucha velocidad; por eso el limite de tenant debe quedar escrito antes de pedir implementacion.
El accidente que queremos evitar es claro: un usuario de Tenant A puede leer proyectos, facturas, archivos, eventos de auditoria o prompts de Tenant B. Buscarwhere tenantIden un code review no alcanza. La implementacion segura propagatenant_idpor autenticacion, sesion, base de datos, facturacion, jobs, busqueda y logs, y usa la base de datos como ultima barrera cuando una consulta de aplicacion se equivoca.
Esta guia usa Claude Code como asistente de implementacion y revision, no como atajo para saltarse seguridad. RLS significa Row Level Security: politicas de PostgreSQL que deciden que filas son visibles o escribibles para el contexto actual. Consulta la documentacion oficial de PostgreSQL sobreRow Security Policies, CREATE POLICY ycurrent_setting / set_config al adaptar los ejemplos.
Como base adicional, revisa autenticacion con Claude Code, control de acceso RBAC y migraciones de base de datos. Multi-tenancy mezcla esas tres areas.
Elegir primero el modelo de aislamiento
Hay tres modelos habituales. Decide uno antes de pedir a Claude Code que toque handlers o schemas.
| Modelo | Cuando conviene | Ventaja | Riesgo |
|---|---|---|---|
| Base compartida, schema compartido | SaaS temprano o medio con muchos clientes pequenos | Menor coste operativo y analitica simple | Un filtro tenant omitido puede filtrar datos |
| Base compartida, schema por tenant | Pocos clientes grandes con mas separacion | Permisos y migraciones algo separados | Las migraciones se complican al crecer schemas |
| Base por tenant | Sectores regulados o contratos estrictos | Backup, claves y fallos se aislan mejor | Coste y despliegue mas complejos |
La pregunta practica es si el modelo seguira funcionando con 100 o 1000 clientes. Para la mayoria de SaaS pequenos y medianos, conviene empezar con base compartida y schema compartido, reforzandotenant_id, RLS, auditoria y restauracion por tenant. Si algun contrato exige separacion fisica, disena una via para mover tenants enterprise concretos a otra base sin reescribir toda la aplicacion.
Tres casos concretos ayudan a aterrizarlo. Primero, un CRM B2B: cuentas, contactos, oportunidades y notas pertenecen a un tenant, y el equipo comercial solo ve su empresa. Segundo, un portal de agencia: el personal de la agencia puede pertenecer a varios tenants, pero el cliente solo ve su portal. Tercero, un SaaS con IA: ejecuciones de prompts, tokens, archivos y almacenamiento se cuentan por tenant para que facturacion y limites de plan sean correctos.
Empieza con una solicitud de revision de diseno:
Quiero convertir este SaaS en multi-tenant.
Asume PostgreSQL compartido y schema compartido.
Clasifica las tablas como tenant-owned, global reference y tablas que requieren PostgreSQL RLS.
No aceptes atajos peligrosos: nada de "los WHERE de aplicacion bastan", nada de logs con request body completo, y nada de jobs que infieran tenant_id despues.
Devuelve areas de cambio, restricciones de DB, casos de prueba y riesgos de migracion.
Propagar tenant_id desde una fuente confiable
No confies en un headerx-tenant-idenviado por el navegador ni en un parametro de ruta como verdad. El flujo seguro es resolver un tenant candidato desde host o path, verificar en el servidor que el usuario autenticado pertenece a ese tenant, y pasar solo eltenantIdverificado a los servicios internos.
Auth.js muestra en su guia oficialExtending the Session como agregar campos derivados del servidor a la sesion. Aun asi, manten la sesion pequena. Debe identificar al usuario; la membresia y el rol del tenant deben consultarse en datos del servidor porque pueden cambiar.
// src/lib/tenant-context.ts
import { z } from "zod";
const hostSchema = z.string().min(1).max(255);
export type SessionUser = {
id: string;
email: string;
};
export type TenantContext = {
tenantId: string;
userId: string;
role: "owner" | "admin" | "member" | "viewer";
requestId: string;
};
type TenantRecord = {
id: string;
subdomain: string | null;
custom_domain: string | null;
};
type MembershipRecord = {
tenant_id: string;
user_id: string;
role: TenantContext["role"];
};
export async function resolveTenantContext(input: {
request: Request;
sessionUser: SessionUser | null;
requestId: string;
findTenantByHost: (host: string) => Promise<TenantRecord | null>;
findMembership: (tenantId: string, userId: string) => Promise<MembershipRecord | null>;
}): Promise<TenantContext> {
if (!input.sessionUser) {
throw new Response("Unauthorized", { status: 401 });
}
const host = hostSchema.parse(new URL(input.request.url).host.toLowerCase());
const tenant = await input.findTenantByHost(host);
if (!tenant) {
throw new Response("Tenant not found", { status: 404 });
}
const membership = await input.findMembership(tenant.id, input.sessionUser.id);
if (!membership) {
throw new Response("Forbidden for this tenant", { status: 403 });
}
return {
tenantId: tenant.id,
userId: input.sessionUser.id,
role: membership.role,
requestId: input.requestId,
};
}
La parte importante es lo que no hace: no acepta el tenant del cliente como verdad. APIs internas y workers deben aplicar la misma regla. Un tenant_id externo es una declaracion; membership, API key vinculada o payload firmado deben probarlo.
Usar PostgreSQL RLS como barrera final
Los filtros de aplicacion siguen siendo utiles, pero una omision no debe convertirse en brecha. En schema compartido, cada tabla propiedad de tenant necesitatenant_id, foreign keys, indices con tenant y politicas RLS. En PostgreSQL, el owner de la tabla y roles conBYPASSRLSpueden saltar RLS, asi que el rol de aplicacion no debe ser owner. En tablas criticas, usaFORCE ROW LEVEL SECURITY.
-- db/migrations/20260602_multi_tenant_rls.sql
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE tenants (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
slug text NOT NULL UNIQUE,
name text NOT NULL,
plan text NOT NULL DEFAULT 'starter',
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE app_users (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
email text NOT NULL UNIQUE,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE tenant_memberships (
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES app_users(id) ON DELETE CASCADE,
role text NOT NULL CHECK (role IN ('owner', 'admin', 'member', 'viewer')),
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (tenant_id, user_id)
);
CREATE TABLE projects (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
name text NOT NULL,
status text NOT NULL DEFAULT 'active',
created_by uuid NOT NULL REFERENCES app_users(id),
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX projects_tenant_id_id_idx ON projects (tenant_id, id);
CREATE INDEX tenant_memberships_user_id_tenant_id_idx ON tenant_memberships (user_id, tenant_id);
ALTER TABLE tenant_memberships ENABLE ROW LEVEL SECURITY;
ALTER TABLE tenant_memberships FORCE ROW LEVEL SECURITY;
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
ALTER TABLE projects FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_memberships_isolation
ON tenant_memberships
FOR ALL
USING (tenant_id = nullif(current_setting('app.tenant_id', true), '')::uuid)
WITH CHECK (tenant_id = nullif(current_setting('app.tenant_id', true), '')::uuid);
CREATE POLICY projects_isolation
ON projects
FOR ALL
USING (tenant_id = nullif(current_setting('app.tenant_id', true), '')::uuid)
WITH CHECK (tenant_id = nullif(current_setting('app.tenant_id', true), '')::uuid);
Con connection pools, el contexto debe vivir dentro de una transaccion. Un valor de sesion puede quedar en una conexion reutilizada;set_config(..., true)lo hace local a la transaccion.
// src/db/tenant-db.ts
import { Pool, PoolClient } from "pg";
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
const uuidPattern =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
export async function withTenant<T>(
tenantId: string,
work: (client: PoolClient) => Promise<T>,
): Promise<T> {
if (!uuidPattern.test(tenantId)) {
throw new Error("Invalid tenant id");
}
const client = await pool.connect();
try {
await client.query("BEGIN");
await client.query("SELECT set_config('app.tenant_id', $1, true)", [tenantId]);
const result = await work(client);
await client.query("COMMIT");
return result;
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
}
export async function listProjects(tenantId: string) {
return withTenant(tenantId, async (db) => {
const result = await db.query(
"SELECT id, name, status, created_at FROM projects ORDER BY created_at DESC",
);
return result.rows;
});
}
La consulta omiteWHERE tenant_id = $1a proposito. Si RLS esta activo, PostgreSQL filtra las filas invisibles. En produccion puedes anadir filtros por rendimiento, pero no deben reemplazar RLS.
Alinear autenticacion, facturacion y jobs
La pregunta de autenticacion no es solo si el usuario inicio sesion, sino que puede hacer en este tenant. Con consultores, agencias o soporte en varios tenants, buscar solo poruser_ides peligroso. LlevatenantId, userId, roleyrequestIdpor las fronteras de servicio.
La facturacion usa la misma frontera. Stripe explica lausage-based billing, pero antes de enviar uso debes contar correctamente el uso del tenant. Los limites de plan se aplican en servidor antes de escribir o ejecutar IA, no solo en UI.
// src/lib/plan-limits.ts
type Plan = "starter" | "growth" | "enterprise";
type Metric = "users" | "projects" | "aiRuns";
const PLAN_LIMITS: Record<Plan, Record<Metric, number>> = {
starter: { users: 5, projects: 20, aiRuns: 500 },
growth: { users: 50, projects: 500, aiRuns: 10_000 },
enterprise: { users: 10_000, projects: 1_000_000, aiRuns: 50_000_000 },
};
export function assertPlanLimit(input: {
plan: Plan;
metric: Metric;
currentUsage: number;
increment?: number;
}) {
const nextUsage = input.currentUsage + (input.increment ?? 1);
const limit = PLAN_LIMITS[input.plan][input.metric];
if (nextUsage > limit) {
throw new Response(
JSON.stringify({
error: "PLAN_LIMIT_EXCEEDED",
metric: input.metric,
limit,
nextUsage,
}),
{ status: 402, headers: { "content-type": "application/json" } },
);
}
}
Los jobs son un punto frecuente de fuga. No envies soloprojectIda la cola para que el worker busque globalmente. Inserta eltenantIdverificado y vuelve a entrar enwithTenantdentro del worker.
// src/jobs/send-project-digest.ts
import { withTenant } from "../db/tenant-db";
type ProjectDigestJob = {
tenantId: string;
projectId: string;
requestedBy: string;
};
export async function handleProjectDigestJob(job: ProjectDigestJob) {
return withTenant(job.tenantId, async (db) => {
const project = await db.query(
"SELECT id, name FROM projects WHERE id = $1",
[job.projectId],
);
if (project.rowCount !== 1) {
throw new Error("Project not visible for tenant");
}
await db.query(
"INSERT INTO audit_events (tenant_id, actor_user_id, event_name) VALUES ($1, $2, $3)",
[job.tenantId, job.requestedBy, "project_digest_sent"],
);
});
}
Registrar lo necesario sin filtrar datos
Los logs pueden convertirse en una segunda base de datos con menos controles. LaLogging Cheat Sheet de OWASP insiste en sanitizar eventos y evitar datos sensibles. En SaaS multi-tenant necesitastenantId, requestId, evento, actor y target; no necesitas cookies, Authorization, API keys, prompts completos, body completo ni direccion de facturacion.
// src/lib/safe-log.ts
type LogLevel = "info" | "warn" | "error";
const SENSITIVE_KEYS = new Set([
"authorization",
"cookie",
"password",
"token",
"apiKey",
"prompt",
"secret",
]);
function redact(value: unknown): unknown {
if (!value || typeof value !== "object") return value;
if (Array.isArray(value)) return value.map(redact);
return Object.fromEntries(
Object.entries(value).map(([key, child]) => [
key,
SENSITIVE_KEYS.has(key) ? "[REDACTED]" : redact(child),
]),
);
}
export function tenantLog(level: LogLevel, message: string, fields: Record<string, unknown>) {
const safeFields = redact(fields);
console[level](JSON.stringify({ message, ...safeFields }));
}
Separa logs de auditoria y logs operativos. La auditoria debe responder quien hizo que, en que tenant, sobre que objeto y cuando. No conviertas la auditoria en una copia completa del registro modificado salvo que el producto o la regulacion lo exijan.
Escribir pruebas que intenten romper el aislamiento
Las pruebas felices no alcanzan. Prueba que Tenant A use IDs de Tenant B, que un worker reciba payload inconsistente, que falteapp.tenant_id, que se supere un limite de plan y que el logger reciba campos sensibles.
-- db/tests/rls-smoke-test.sql
\set ON_ERROR_STOP on
BEGIN;
INSERT INTO tenants (id, slug, name)
VALUES
('00000000-0000-4000-8000-000000000001', 'alpha', 'Alpha Inc'),
('00000000-0000-4000-8000-000000000002', 'beta', 'Beta Inc');
INSERT INTO app_users (id, email)
VALUES ('10000000-0000-4000-8000-000000000001', 'masa@example.com');
INSERT INTO projects (id, tenant_id, name, created_by)
VALUES
('20000000-0000-4000-8000-000000000001', '00000000-0000-4000-8000-000000000001', 'Alpha project', '10000000-0000-4000-8000-000000000001'),
('20000000-0000-4000-8000-000000000002', '00000000-0000-4000-8000-000000000002', 'Beta project', '10000000-0000-4000-8000-000000000001');
SELECT set_config('app.tenant_id', '00000000-0000-4000-8000-000000000001', true);
DO $$
DECLARE visible_count integer;
BEGIN
SELECT count(*) INTO visible_count FROM projects;
IF visible_count <> 1 THEN
RAISE EXCEPTION 'RLS failed: expected 1 visible project, got %', visible_count;
END IF;
END $$;
ROLLBACK;
#!/usr/bin/env bash
set -euo pipefail
: "${DATABASE_URL:?DATABASE_URL is required}"
psql "$DATABASE_URL" -f db/migrations/20260602_multi_tenant_rls.sql
psql "$DATABASE_URL" -f db/tests/rls-smoke-test.sql
Pide a Claude Code criterios adversarios:
Agrega pruebas de fuga multi-tenant:
1. Una sesion de Tenant A usando projectId de Tenant B devuelve 403 o 404.
2. Una consulta sin app.tenant_id devuelve cero filas o falla cerrada.
3. Un job sin tenantId es rechazado.
4. Un exceso de plan devuelve 402 antes de escribir en DB.
5. Los logs nunca incluyen authorization, cookie, prompt, token ni apiKey.
Explica que implementacion incorrecta fallaria cada prueba y muestra comandos/resultados tras corregir.
Evitar trampas de migracion
Al adaptar un SaaS existente, el error mas comun es agregar untenant_idnullable, lanzar y prometer limpieza despues. La nulabilidad temporal puede existir durante backfill, pero el lanzamiento no termina hasta tenerNOT NULL, foreign keys, unique constraints por tenant, indices, RLS y pruebas.
Cinco trampas merecen atencion. Una, mezclar datos globales y datos del tenant: codigos de pais pueden ser globales, configuraciones de cliente no. Dos, rutas de archivos sin tenant; la base puede estar protegida pero el storage no. Tres, indices de busqueda sintenant_idpara Algolia, Meilisearch u OpenSearch. Cuatro, desactivar RLS en el panel admin; soporte debe usar impersonation explicita y auditada. Cinco, no probar restauracion por tenant; sin ese camino, un incidente empuja a restaurar a todos.
Crea un plan para convertir tablas existentes a multi-tenancy.
Phase 1: agregar columnas tenant_id y mapear registros existentes.
Phase 2: crear SQL de backfill y SQL de validacion para NULL restantes.
Phase 3: agregar NOT NULL, foreign keys, unique constraints por tenant e indices.
Phase 4: activar RLS y proponer donde usar FORCE ROW LEVEL SECURITY.
Phase 5: probar APIs, jobs, logs e indices de busqueda contra fugas cross-tenant.
Incluye condiciones de rollback y SQL de verificacion en cada fase.
Prompts seguros para Claude Code
El prompt tambien debe contener el limite de tenant. Evita “hazlo multi-tenant rapido” o “los admins pueden ver todo”. Un prompt seguro incluye prohibiciones, pruebas, fuentes y alcance.
Implementas comportamiento multi-tenant seguro.
Alcance permitido: src/app, src/lib, db/migrations y tests.
Requisitos:
- Resolver tenant_id en servidor; no confiar en IDs de cliente.
- Usar PostgreSQL RLS en toda tabla propiedad de tenant.
- En conexiones pooled, llamar set_config('app.tenant_id', value, true) dentro de una transaccion.
- Propagar tenantId por auth, billing, jobs, logs y search indexing.
- Agregar pruebas que intenten lecturas y escrituras cross-tenant.
Prohibido:
- Desactivar RLS.
- Confiar en x-tenant-id.
- Loguear Authorization, Cookie, API keys o prompts completos.
- Marcar terminado sin pruebas.
El reporte final debe incluir archivos cambiados, comandos, fallos encontrados y riesgo residual.
La documentacion deClaude Code Security de Anthropic cubre permisos, prompt injection y responsabilidad de revisar cambios. En repositorios SaaS sensibles, versiona reglas de proyecto, comandos permitidos y conexiones MCP.
CTA y entrega practica
Multi-tenancy no es la funcion mas vistosa, pero vende confianza en SaaS B2B. Si puedes explicar aislamiento de datos, auditoria, limites de plan, acceso de soporte y restauracion por tenant, las revisiones de seguridad son mucho mas faciles.
Para equipos que adoptan Claude Code, pon las reglas de tenant enCLAUDE.mdy fijalas con pruebas. En cada review pregunta: se propagotenant_id, RLS bloquea errores, los jobs estan scoped y los logs no filtran secretos. Los equipos pueden empezar conformacion y consultoria Claude Code. Los builders individuales pueden usar lachuleta gratuita para convertir esta lista en prompts repetibles.
Resultado al probarlo
Cuando Masa probo este patron en un CRM pequeno, la primera fuga no aparecio en una API route sino en el worker de resumen diario. La UI enviabatenantId, pero el payload de cola solo llevabaprojectId, asi que el worker podia buscar fuera del tenant previsto. Al forzarwithTenanttambien en workers y agregar una prueba SQL para falta deapp.tenant_id, el fallo se pudo reproducir y corregir. La instruccion mas eficaz para Claude Code fue “primero escribe una prueba donde Tenant A intenta leer Tenant B”.
PDF gratis: cheatsheet de Claude Code
Introduce tu email y descarga una hoja con comandos, hábitos de revisión y flujos seguros.
Cuidamos tus datos y no enviamos spam.
Sobre el autor
Masa
Ingeniero enfocado en workflows prácticos con Claude Code.
Artículos relacionados
Workflow de Obsidian a CLAUDE.md con Claude Code
Convierte notas de trabajo de Obsidian en notas operativas de CLAUDE.md para no repetir contexto.
Claude Code Revenue CTA Routing: de artículos a PDF, Gumroad y consulta
Un flujo con Claude Code para dirigir lectores a PDF gratis, Gumroad o consulta según intención.
Reglas de handoff para equipos con Claude Code: evidencia, permisos, rollback e ingresos
Formato práctico para entregar trabajo de Claude Code con pruebas, permisos, rollback, PDF gratis, Gumroad y consulta.