Reporte de entrega · Bloque 0

ComensIA — Cimiento de aislamiento multi-tenant

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-28 Autor: Diego Monge Loría — DM-IA Solutions Servidor: terciario srv1582179 · 2.24.194.226 Ruta: /root/comensia Estado: 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ó

Decisiones de arquitectura del cimiento

DecisiónPor qué
Aislamiento en DB (RLS) además de en la appDefensa 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íticasAuditable y sin atajos: el Owner NO es un superusuario de Postgres que se saltaría el aislamiento.
Rol de runtime sin superuser ni BYPASSRLSUn superusuario ignoraría RLS por completo; el rol restringido garantiza que FORCE RLS aplique.
GUC set_config(..., true) local a transacciónEvita la fuga de scope entre tenants al reutilizarse conexiones del pool.
Índices únicos parciales WHERE deleted_at IS NULLEl soft delete rompe los UNIQUE ingenuos; el parcial permite reusar slug/email de borrados.
JWT RS256 con llaves PEM por entornoEstándar global DM-IA (regla transversal). No se usa HS256 (no es MVP de cliente único).

Arquitectura del cimiento

Stack: FastAPI · SQLAlchemy async · PostgreSQL 16 + pgvector · Redis 7 · Alembic async · pytest async.

Dos roles de base de datos

RolprivilegiosUso
comensia_adminSUPERUSER (bootstrap del contenedor)Solo migraciones (CREATE ROLE / EXTENSION / políticas). Bypassa RLS.
comensia_appNOSUPERUSER · NOBYPASSRLS · LOGINLa aplicación en runtime. Sujeto a RLS siempre.
Verificado en vivo: SELECT rolname, rolsuper, rolbypassrls FROM pg_rolescomensia_app | f | f (sin superusuario, sin bypass).

Caminos de acceso a datos

Mapa del repositorio

# app/ — backend tipado (mypy strict)
core/      config · logging(JSON) · context(TenantContext) · security(argon2/JWT RS256)
db/        base(engine/sessionmaker) · mixins · session(puente RLS)
models/    chain · restaurant · user · audit_log · enums
api/       deps(principal + get_session) · auth · health · schemas
services/  auth_service · token_revocation(Redis)
middleware request_id   ·   main  app factory + lifespan
# alembic/ — migración 0001 (rol, extensiones, tablas, RLS, grants)
# tests/ — conftest(seed) · test_leak(fuga) · test_auth

Observabilidad base

Esquema creado

Todas las entidades de negocio con soft delete + audit + timestamps. audit_logs es append-only.

chains RLS + FORCE

columnatipo
iduuid PK · gen_random_uuid()
namevarchar(200)
brand_configjsonb · default '{}'
created_at / updated_attimestamptz (trigger updated_at)
deleted_attimestamptz NULL (soft delete)
created_by / updated_by / deleted_byuuid → users(id)

restaurants RLS + FORCE

columnatipo
iduuid PK
chain_iduuid NULL → chains(id) — afiliado directo al Owner
namevarchar(200)
slugvarchar(120) · único parcial
brand_configjsonb · default '{}'
order_confirmation_modeenum · default requires_authorization
timestamps · soft delete · auditigual que chains

users sin RLS (login pre-contexto)

columnatipo
iduuid PK
emailvarchar(320) · único parcial
hashed_passwordvarchar(255) · argon2
levelenum tenant_level (owner|chain|restaurant)
scope_iduuid NULL (NULL owner · chain_id · restaurant_id)
is_activeboolean · default true
timestamps · soft delete · auditincluidos

audit_logs RLS + FORCE append-only

columnatipo
iduuid PK
actor_iduuid → users(id)
scope_levelenum tenant_level
scope_iduuid NULL
action / entity_typevarchar(120)
entity_iduuid NULL
metadatajsonb · default '{}'
created_attimestamptz (sin soft delete, sin updated)

Índices únicos parciales (gotcha de soft delete)

índicedefinición
uq_restaurants_slug_activeUNIQUE (slug) WHERE deleted_at IS NULL
uq_users_email_activeUNIQUE (email) WHERE deleted_at IS NULL

Apoyo: ix_restaurants_chain_id, ix_audit_logs_scope(scope_level, scope_id).

Verificación en vivo del esquema

-- 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 ALL
USING (
  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):

# app/db/session.py
_APPLY_GUC = text(
    "SELECT set_config('app.level', :level, true), "
    "set_config('app.scope_id', :scope_id, true)"
)

async def apply_tenant_guc(session, ctx):
    await session.execute(_APPLY_GUC,
        {"level": ctx.level.value, "scope_id": ctx.guc_scope_value})
        # owner → scope_id = ''  ·  chain/restaurant → uuid en texto

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

Test de fuga — tests/test_leak.py

# pytest -v tests/test_leak.py
test_chain_a_sees_its_restaurants_not_b PASSED
test_restaurant_a1_sees_only_itself PASSED
test_owner_sees_all_restaurants PASSED
test_audit_isolation_restaurant_a1 PASSED
test_audit_isolation_chain_a PASSED
test_audit_isolation_owner PASSED
test_restaurant_a1_cannot_read_b1_by_id PASSED
test_chain_a_cannot_insert_restaurant_for_chain_b PASSED
test_chain_a_cannot_move_own_restaurant_to_chain_b PASSED
test_concurrent_tenants_do_not_leak PASSED
test_no_guc_residue_after_tenant_transaction PASSED
============================== 11 passed in 2.21s ==============================
Escenario del enunciadoCubierto porEstado
Aislamiento por nivel (restaurants)chain_a / restaurant_a1 / owner
Aislamiento por nivel (audit_logs)audit_isolation_*
Negativo: lectura cross-tenant vacíacannot_read_b1_by_id
Negativo: INSERT/UPDATE cross-tenant rechazado (WITH CHECK)cannot_insert_* / cannot_move_*
Estrés de concurrencia sobre el poolconcurrent_tenants_do_not_leak
No-residuo de pool (GUC local)no_guc_residue_*

Tests de auth — tests/test_auth.py

# pytest -v tests/test_auth.py
test_login_ok PASSED
test_login_wrong_password PASSED
test_login_unknown_email PASSED
test_me_reflects_token_context PASSED
test_me_requires_auth PASSED
test_refresh_rotates_and_revokes_old PASSED
test_logout_revokes_access PASSED
test_token_context_for_chain_user PASSED
============================== 8 passed in 3.14s ===============================

Hashes de archivos críticos (sha256)

archivosha256 (12)
app/db/session.py7f64f45621d9
alembic/versions/0001_initial.py050521327b50
app/api/deps.py79fe04d92542
app/core/context.py29ea4a97a276

Entorno & arranque

Variables de entorno (ver .env.example, sin valores reales)

variabledescripción
DATABASE_URLDSN async del rol restringido comensia_app
DATABASE_ADMIN_URLDSN admin — solo Alembic
APP_DB_PASSWORDpassword del rol que crea la migración 0001
REDIS_URLlista de revocación de tokens
JWT_PRIVATE_KEY / JWT_PUBLIC_KEYllaves PEM RS256 (una línea con \n escapados)
ACCESS_TOKEN_TTL_SECONDS900 (15 min)
REFRESH_TOKEN_TTL_SECONDS604800 (7 días)
DB_POOL_SIZE / DB_MAX_OVERFLOWpool del engine async

Servicios (Docker Compose)

servicioimagenhosthealthcheck
postgrespgvector/pgvector:pg16:55432pg_isready
redisredis:7:56379redis-cli ping
backendbuild .: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)

Desviaciones del enunciado (reportadas proactivamente)

PuntoQué 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ó

Próximas acciones

Activación operativa final (la hace Diego)

Hooks para bloques posteriores (ya cableados como TODO)

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.