Repositorio base de la plataforma SaaS de tres niveles
Owner → Cadena → Restaurante. Este bloque construye únicamente el cimiento de
aislamiento (RLS + app), sobre el que se apoyarán los bloques de negocio. La corrección
del aislamiento fue el criterio rector.
Fecha: 2026-05-28Autor: Diego Monge Loría — DM-IA SolutionsServidor: terciario srv1582179 · 2.24.194.226Ruta: /root/comensiaEstado:COMPLETO · suite verde · mypy estricto OK
Resumen ejecutivo
19
tests, 100% verdes
11
en el test de fuga
0
errores mypy (strict)
3
políticas RLS (FORCE)
4
tablas + 2 índices parciales
~2.2k
LOC Python tipado
Resultado: el cimiento está completo y verificado de punta a punta:
docker compose up levanta Postgres+pgvector y Redis sanos,
alembic upgrade head reconstruye todo desde cero (rol de app, tablas,
índices únicos parciales y políticas RLS), mypy estricto pasa sin errores y
la suite completa —incluido el test de fuga con concurrencia sobre el pool— está verde.
El backend responde GET /health con HTTP 200.
Qué se construyó
Configuración tipada (pydantic-settings) + .env.example, cero secretos en código.
Logging estructurado JSON con request_id y tenant_id por línea.
TenantContext inmutable en contextvar; falla ruidosamente si falta.
Capa de datos con el puente RLS: set_config(..., true) local a transacción.
Mixins base (UUID / Timestamp / SoftDelete / Audit) y las 4 entidades del bloque.
Auth: argon2 + JWT RS256 (access 15 min + refresh rotativo + revocación en Redis).
Migración Alembic async que crea el rol comensia_app (sin superuser / sin BYPASSRLS).
Tests pytest async, incluido el test de fuga (aislamiento, negativo, concurrencia, no-residuo).
Observabilidad: middleware request_id + GET /health (Postgres + Redis).
Decisiones de arquitectura del cimiento
Decisión
Por qué
Aislamiento en DB (RLS) además de en la app
Defensa en profundidad: la app filtra (1ª línea), RLS atrapa cualquier bug (última línea).
Owner cross-tenant vía app.level='owner' en las políticas
Auditable y sin atajos: el Owner NO es un superusuario de Postgres que se saltaría el aislamiento.
Rol de runtime sin superuser ni BYPASSRLS
Un superusuario ignoraría RLS por completo; el rol restringido garantiza que FORCE RLS aplique.
GUC set_config(..., true) local a transacción
Evita la fuga de scope entre tenants al reutilizarse conexiones del pool.
Índices únicos parcialesWHERE deleted_at IS NULL
El soft delete rompe los UNIQUE ingenuos; el parcial permite reusar slug/email de borrados.
JWT RS256 con llaves PEM por entorno
Estándar global DM-IA (regla transversal). No se usa HS256 (no es MVP de cliente único).
-- pg_tables audit_logs · chains · restaurants · users · alembic_version
-- pg_extension pgcrypto · vector · plpgsql
-- RLS (relrowsecurity | relforcerowsecurity)
audit_logs t | t chains t | t restaurants t | t users f | f-- pg_policy audit_logs_isolation · chains_isolation · restaurants_isolation
Políticas RLS aplicadas
Las tres tablas tienen ENABLE + FORCE ROW LEVEL SECURITY (FORCE para que el
aislamiento aplique incluso al dueño de la tabla). Políticas FOR ALL con USING = WITH CHECK
(salvo audit_logs) para impedir tanto leer como escribir filas de otro tenant.
Ajuste de implementación respecto al SQL del enunciado: el cast del scope se envuelve en
NULLIF(current_setting('app.scope_id', true), '')::uuid. current_setting es
STABLE, así que Postgres pliega el cast antes del corto-circuito del OR y la
cadena vacía del owner reventaba con invalid input syntax for type uuid: "". El NULLIF
neutraliza la cadena vacía a NULL — exactamente el intent declarado ("la cadena vacía nunca se
castea a uuid"). Detectado y corregido por el test de fuga (caso owner).
chains_isolation
CREATE POLICY chains_isolation ON chains FOR ALLUSING (
current_setting('app.level', true) = 'owner'OR (current_setting('app.level', true) = 'chain'AND id = NULLIF(current_setting('app.scope_id', true), '')::uuid)
)
WITH CHECK ( /* misma expresión */ );
restaurants_isolation
USING (
current_setting('app.level', true) = 'owner'OR (level = 'chain'AND chain_id = NULLIF(scope,'')::uuid)
OR (level = 'restaurant'AND id = NULLIF(scope,'')::uuid)
) -- WITH CHECK idéntico
audit_logs_isolation
USING (
level = 'owner'OR (level = 'chain'AND (
(scope_level='chain'AND scope_id = NULLIF(scope,'')::uuid)
OR (scope_level='restaurant'AND scope_id IN (
SELECT id FROM restaurants
WHERE chain_id = NULLIF(scope,'')::uuid))))
OR (level = 'restaurant'AND scope_level='restaurant'AND scope_id = NULLIF(scope,'')::uuid)
)
WITH CHECK (true); -- cualquier nivel inserta su propia traza; la LECTURA queda restringida por USING
El Owner corta por OR primero (rama 'owner'). La subconsulta de audit_logs sobre
restaurants hereda el mismo contexto RLS, así que para una cadena devuelve exactamente sus restaurantes.
El puente set_config local a transacción
Es el punto más crítico del bloque: por qué no hay fuga de tenant bajo pooling.
El footgun
Con SQLAlchemy async + pool de conexiones, las conexiones se reutilizan entre requests. Si se
setea la variable de sesión de forma global (SET app.scope_id = ...), ese valor persiste
cuando la conexión vuelve al pool y la toma el siguiente request — que podría ser de otro tenant.
Eso es una fuga de aislamiento.
La solución
Toda unidad de trabajo corre dentro de una transacción explícita y aplica el contexto a la GUC
antes de cualquier query de negocio, con set_config parametrizado (binds, nunca
interpolación de strings) y local a la transacción (tercer argumento true):
Al ser local, el valor desaparece en el COMMIT/ROLLBACK y no persiste cuando
la conexión vuelve al pool. Eso es lo que previene la fuga. El test
test_no_guc_residue_after_tenant_transaction lo prueba: tras una transacción de Cadena A,
una transacción nueva sin GUC observa app.scope_id vacío.
Garantía probada: 40 operaciones concurrentes de 4 tenants distintos
intercaladas con asyncio.gather sobre el mismo pool — cada una ve exactamente sus filas, ninguna ve las de otro.
Tests & mypy
19
tests verdes
11
test de fuga
8
tests de auth
31
archivos · mypy OK
mypy estricto
$ mypy
Success: no issues found in 31 source files
Variables de entorno (ver .env.example, sin valores reales)
variable
descripción
DATABASE_URL
DSN async del rol restringido comensia_app
DATABASE_ADMIN_URL
DSN admin — solo Alembic
APP_DB_PASSWORD
password del rol que crea la migración 0001
REDIS_URL
lista de revocación de tokens
JWT_PRIVATE_KEY / JWT_PUBLIC_KEY
llaves PEM RS256 (una línea con \n escapados)
ACCESS_TOKEN_TTL_SECONDS
900 (15 min)
REFRESH_TOKEN_TTL_SECONDS
604800 (7 días)
DB_POOL_SIZE / DB_MAX_OVERFLOW
pool del engine async
Servicios (Docker Compose)
servicio
imagen
host
healthcheck
postgres
pgvector/pgvector:pg16
:55432
pg_isready
redis
redis:7
:56379
redis-cli ping
backend
build .
:8085
/health → 200
Arranque
$ docker compose up -d postgres redis # sanos por healthcheck
$ docker compose run --rm backend alembic upgrade head
$ docker compose up -d backend
$ curl http://localhost:8085/health
{"status":"ok","postgres":true,"redis":true} HTTP 200
# Tests + mypy (host con venv y .env apuntando a :55432 / :56379)
$ alembic upgrade head
$ mypy # estricto, sin errores
$ pytest # 19 passed
Transparencia total
Lo que NO se tocó (a propósito)
Módulos de negocio (menú, IA, órdenes, pagos): pertenecen a bloques posteriores. No se construyó nada de eso.
Otros proyectos del servidor: cero contacto. ComensIA usa puertos altos (55432/56379/8085) para no chocar con CTT/SIBO/Galia/etc.
El rol y las extensiones no se eliminan en el downgrade de la migración (pueden estar en uso por otros objetos del cluster). Decisión explícita y comentada.
Desviaciones del enunciado (reportadas proactivamente)
Punto
Qué se hizo y por qué
Severidad
Cast del scope en RLS
Se envolvió en NULLIF(..., '')::uuid. El SQL literal del enunciado revienta para el owner
(cadena vacía → ::uuid) porque current_setting es STABLE y Postgres pliega el cast antes
del OR. El NULLIF implementa exactamente el intent declarado. Detectado por el test de fuga.
Media
Algoritmo JWT
Se usó RS256 con llaves PEM (el enunciado no fijó algoritmo; el CLAUDE.md global lo manda como
regla transversal). Access 15 min, refresh 7 días rotativo, revocación en Redis — todo según el enunciado.
Baja
Endpoint extra GET /auth/me
Introspección de auth (devuelve level/scope_id del principal). Se agregó para poder testear que el
TenantContext se construye correctamente desde los claims. Es auth-scope, no un módulo de negocio.
Baja
Emails de seed
Se usa el dominio @comensia.com en los tests: email-validator rechaza el TLD
reservado .test. Solo afecta datos de prueba.
Baja
Relacionado que se vio pero no se modificó
El enunciado pide grants para la app; se otorgó SELECT, INSERT, UPDATE en chains/restaurants/users y
SELECT, INSERT en audit_logs — sin DELETE, reforzando "nunca DELETE físico desde la app".
Próximas acciones
Activación operativa final (la hace Diego)
Apuntar DNS comensia.dm-ia.com → 2.24.194.226 y correr certbot para SSL.
El nginx en terciario ya sirve este reporte por HTTP; con DNS+SSL queda en https://comensia.dm-ia.com.
Definir las credenciales productivas reales (rol comensia_app, llaves RS256) en el .env del entorno productivo — los valores del repo son solo de desarrollo.
Hooks para bloques posteriores (ya cableados como TODO)
Inicializar OpenTelemetry y Sentry en app/main.py (puntos de enganche dejados, sin proveedor).
Los módulos de negocio (menú, IA, órdenes, pagos) se apoyan en este cimiento: todo acceso a datos debe
pasar por get_session / tenant_session para heredar el aislamiento.
El cimiento queda listo para que el resto de la plataforma se construya encima sin volver a
tocar el aislamiento. La regla de oro para todo bloque futuro: nunca acceder a datos de tenant fuera de un
tenant_session/get_session.