Multi-tenancy / Multi-tenancy¶
OrpycaMCP soporta múltiples instituciones en una sola instalación usando schema isolation en PostgreSQL.
¿Qué hace?¶
Cada institución (tenant) tiene su propio schema de base de datos (tenant_{slug}), completamente aislado de los demás. El schema public solo contiene el registro de instituciones.
¿Por qué existe?¶
- Permite que una entidad gubernamental regional sirva a múltiples municipios
- Reduce costos operativos respecto a instalaciones separadas
- Garantiza aislamiento de datos (cada tenant no puede ver datos de otros)
¿Cómo agregar una institución?¶
El alta es una operación de plataforma, no de aplicación: /api/v1/tenants no se expone por el
api-gateway, a propósito (cualquier usuario autenticado de una entidad podría enumerar y escribir
el registro de las demás; hallazgo crítico 2026-07). La hace scripts/init_tenant.py (que
scripts/init-tenant.sh ejecuta en un contenedor, con el rol propietario de la base):
Esto:
1. Valida el slug (^[a-z0-9-]+$: minúsculas, dígitos y guion, sin guion bajo) y lo registra en public.tenants
2. Crea el schema tenant_gobernacion_caldas (el guion pasa a guion bajo) y el GRANT USAGE del rol de aplicación
3. Aplica las migraciones .sql numeradas de cada servicio (migrations/global/ y migrations/tenant/), con el ejecutor migrations/runner.py y el libro único public.schema_migrations
4. Pasa la entidad de provisioning_status='pending' a active
POST /tenants del tenant-service (interno, sin ruta en el gateway) solo registra la fila pending y deja
su asiento en audit_log: el servicio opera con el rol de aplicación y no ejecuta DDL. Tampoco toca el
almacén de objetos: los buckets (orpycamcp-{slug}-documents, y los de preservación) los crea el
storage-service la primera vez que los necesita.
¿Qué puede salir mal?¶
- Slug duplicado: el sistema devuelve
409 Conflict - Slug no válido (mayúsculas, guion bajo, guion inicial, menos de 2 o más de 54 caracteres; el mismo validador que todos los servicios,
orpycamcp_common.tenant):422; el schema se llamatenant_{slug}y PostgreSQL trunca los identificadores a 63 bytes sin avisar - Schema ya existe: el provisioning es idempotente, no falla
- Migración fallida: la entidad queda en
pendingy no atiende peticiones hasta que la provisión termine
¿Cómo funciona el aislamiento en cada request?¶
El gateway valida el JWT (claim tenant_slug) y reenvía el tenant al servicio. La dependencia
get_tenant_conn valida el slug con orpycamcp_common.tenant (un solo validador: ^[a-z0-9][a-z0-9-]{1,53}$, falla cerrado con 400 invalid_tenant), lo
convierte en nombre de schema y lo entrecomilla antes de fijar el search_path en una conexión
asyncpg; el nombre de un schema no admite $1, así que la validación es lo que impide la inyección:
schema = tenant_schema(slug) # ^[a-z0-9][a-z0-9-]{1,53}$ -> "tenant_" + slug.replace("-", "_")
async with pool.acquire() as conn:
await conn.execute(f'SET search_path = "{schema}", public')
Todas las consultas de esa conexión operan en el schema correcto. Nunca interpole un slug sin
validar ni sin comillas en un SET search_path.
Nomenclatura de recursos¶
| Recurso | Patrón | Ejemplo |
|---|---|---|
| Schema PostgreSQL | tenant_{slug con guion → guion bajo} |
tenant_gobernacion_caldas |
| Bucket de documentos | orpycamcp-{slug}-documents |
orpycamcp-gobernacion-caldas-documents |
| Bucket de preservación (WORM) | orpycamcp-{slug}-preservation |
orpycamcp-gobernacion-caldas-preservation |
| Stream Redis (compartido por todas las entidades) | orpycamcp.{service}.events |
orpycamcp.document.events |
El stream no lleva el slug: es uno por servicio, y la entidad viaja dentro del sobre firmado del evento (ADR-010).
Estado de la entidad¶
Una entidad solo se atiende si está operativa: public.tenants.status = 'active',
provisioning_status = 'active' y su schema existe. El api-gateway lo hace cumplir con el
tenant_estado de /auth/validate (la suspensión surte efecto en ≤ 15 s, el TTL de la caché de
validación), y cada get_tenant_conn lo vuelve a comprobar antes de fijar el search_path. Las
rutas públicas responden el mismo 404 a una entidad inexistente, dada de baja o sin aprovisionar; una entidad suspendida conserva las lecturas públicas (verificación, OAI, CMIS, publicidad de la eliminación) y sus escrituras responden 503 canal_no_disponible.
Excepción de conservación (B10, ADR-035 §8): la suspensión detiene la operación, no la obligación
de conservar. storage-service admite para una entidad suspended (nunca inactive) solo las rutas
internas de la lista explícita RUTAS_DE_CONSERVACION (app/core/conservacion.py) con
X-Internal-Token válido, y asienta cada admisión (entidad.conservacion_admitida); las fases de
conservación de archive-service (retención WORM, fixity) y el job de renovación LTA de signature-service
recorren entidades active y suspended; el empaquetado de un AIP nuevo espera a la reactivación
(necesita los anexos, que document-service veta), mientras la retención del AIP existente sigue.
Todo lo demás sigue vetado.
Detalle y alternativas en ADR-035.