Skip to content

Estrategia Multi-Tenant - Portal PWA

Concepto

El Portal PWA implementa multi-tenancy por dominio: cada empresa/tenant tiene su propio dominio web que identifica automáticamente al tenant y enruta al contexto correcto.

Patrón Arquitectónico

https://ctacte.empresaA.com.ar → Tenant A (DB: empresa_a)
https://portal.clubXYZ.com     → Tenant B (DB: club_xyz)
https://clientes.ferreteria.ar → Tenant C (DB: ferreteria_z)

Ventajas:

  • ✅ Identidad propia por empresa (branding completo)
  • ✅ Aislamiento de datos garantizado
  • ✅ Escalable: agregar tenants sin cambiar código
  • ✅ SEO amigable
  • ✅ Una sola codebase para todos

Resolución de Tenant

Flujo Completo

1. Cliente accede: https://ctacte.empresaA.com.ar

2. DNS resuelve → Servidor (IP compartida: 123.45.67.89)

3. Apache/Nginx recibe request con Host: ctacte.empresaA.com.ar

4. PortalAuthMiddleware extrae host del request

5. Query a DB ini:
   SELECT * FROM tenant_domains
   WHERE domain = 'ctacte.empresaA.com.ar'
   AND status = 'active'

6. Resultado: {
     tenant_id: 1,
     sistema_id: 5,
     database: 'empresa_a',
     schema: 'public',
     branding: {...}
   }

7. ConnectionMiddleware configura conexión:
   - Database: empresa_a
   - Schema: public
   - Search path: public, suc0001, ...

8. Request procede con contexto de tenant inyectado

Código PHP (PortalAuthMiddleware)

php
public function process(Request $request, RequestHandler $handler): Response
{
    // 1. Extraer host
    $host = $request->getUri()->getHost();

    // 2. Resolver tenant
    $tenantDomain = $this->tenantDomainModel->findByDomain($host);

    if (!$tenantDomain || $tenantDomain['status'] !== 'active') {
        throw new UnauthorizedException('Tenant no encontrado o inactivo');
    }

    // 3. Inyectar contexto
    $tenantContext = [
        'tenant_id' => $tenantDomain['id'],
        'sistema_id' => $tenantDomain['sistema_id'],
        'database' => $tenantDomain['database'],
        'schema' => $tenantDomain['schema_default'],
        'branding' => json_decode($tenantDomain['branding_config'], true),
        'domain' => $tenantDomain['domain']
    ];

    $request = $request->withAttribute('tenant_context', $tenantContext);

    // 4. Continuar con ConnectionMiddleware
    return $handler->handle($request);
}

Aislamiento de Datos

Nivel de Base de Datos

Cada tenant tiene su propia base de datos:

PostgreSQL Server
├── DB: ini (global - sistema)
├── DB: empresa_a (Tenant A)
├── DB: club_xyz (Tenant B)
└── DB: ferreteria_z (Tenant C)

Aislamiento:

  • ✅ Físico: datos en bases de datos separadas
  • ✅ Seguro: imposible acceder a datos de otro tenant
  • ✅ Performance: sin contención entre tenants
  • ✅ Backups: individuales por tenant

Nivel de Schema (Opcional)

//TODO: Ver Caso de ordcon en nivel 1 y casos de varios ordcon nivel 2

Dentro de cada DB, multi-schema por sucursal/caja:

DB: empresa_a
├── Schema: public (LEVEL_EMPRESA)
├── Schema: suc0001 (Sucursal 1)
└── Schema: suc0001caja001 (Caja 1 de Sucursal 1)

Para Portal: Solo se usa public (nivel empresa).

Configuración de Dominios

Wildcard DNS

Opción 1: Subdominio wildcard

*.bautista.com → A record → 123.45.67.89

Permite:

  • ctacte.bautista.com → Tenant por defecto
  • empresaA.bautista.com → Tenant A
  • clubXYZ.bautista.com → Tenant B

Opción 2: Dominios personalizados

Cada empresa puede tener su propio dominio:

ctacte.empresaA.com → CNAME → portal.bautista.com
portal.clubXYZ.com  → CNAME → portal.bautista.com
clientes.ferreteria.ar → A record → 123.45.67.89

Branding por Tenant

Configuración en tenant_domains

json
{
  "branding_config": {
    "app_name": "Portal de Clientes Empresa A",
    "short_name": "Portal EmpA",
    "logo_url": "https://cdn.empresaA.com/logo.png",
    "primary_color": "#1e40af",
    "secondary_color": "#3b82f6",
    "theme_color": "#1e40af",
    "background_color": "#ffffff",
    "font_family": "Inter, sans-serif"
  }
}

Aplicación en Frontend

1. Meta Tags Dinámicos

html
<!-- Inyectado por backend en HTML -->
<meta name="branding" content='{"primary_color":"#1e40af",...}' />
<meta name="theme-color" content="#1e40af" />
<link rel="manifest" href="/manifest.json?tenant=1" />

3. Manifest Dinámico

javascript
// API endpoint: GET /api/manifest.json?tenant={tenant_id}
app.get("/api/manifest.json", (req, res) => {
  const tenant = getTenantContext(req);
  const branding = tenant.branding_config;

  res.json({
    name: branding.app_name,
    short_name: branding.short_name,
    theme_color: branding.theme_color,
    background_color: branding.background_color,
    icons: [
      {
        src: branding.logo_url || "/icons/icon-192x192.png",
        sizes: "192x192",
        type: "image/png",
      },
    ],
  });
});

Seguridad Multi-Tenant

1. Validación de Propiedad

Siempre verificar que recursos pertenecen al tenant actual:

php
// En cada Service
public function getDeudas(int $clienteId, array $tenantContext): array
{
    // 1. Conectar a DB del tenant
    $this->connectionManager->setCurrentDatabase($tenantContext['database']);

    // 2. Verificar que cliente pertenece al tenant
    $cliente = $this->clienteModel->findById($clienteId);
    if (!$cliente) {
        throw new NotFoundException('Cliente no encontrado en este tenant');
    }

    // 3. Retornar datos
    return $this->cuentaCorriente->getMovimientosSinPago($clienteId);
}

2. Auditoría

Loggear todos los accesos con contexto de tenant:

php
$this->logger->info('Portal access', [
    'tenant_id' => $tenantContext['tenant_id'],
    'domain' => $tenantContext['domain'],
    'cliente_id' => $clienteId,
    'ip' => $request->getServerParams()['REMOTE_ADDR'],
    'action' => 'get_deudas'
]);

3. Rate Limiting por Tenant

php
// Límite de requests por dominio
$key = "rate_limit:{$tenantContext['domain']}";
$requests = $redis->incr($key);
$redis->expire($key, 60); // 1 minuto

if ($requests > 100) {
    throw new TooManyRequestsException('Límite de requests excedido');
}

Onboarding de Nuevo Tenant

Script CLI: configure-tenant.php

php
#!/usr/bin/env php
<?php
// Usage: php configure-tenant.php --sistema-id=5 --domain=ctacte.empresaA.com.ar

require __DIR__ . '/vendor/autoload.php';

$options = getopt('', ['sistema-id:', 'domain:', 'database:']);

$tenantDomainModel = new TenantDomainModel($pdo);

$tenantId = $tenantDomainModel->create([
    'sistema_id' => $options['sistema-id'],
    'domain' => $options['domain'],
    'database' => $options['database'] ?? 'empresa_' . $options['sistema-id'],
    'schema_default' => 'public',
    'branding_config' => json_encode([
        'app_name' => 'Portal de Clientes',
        'short_name' => 'Portal',
        'primary_color' => '#1e40af'
    ]),
    'status' => 'active'
]);

echo "Tenant configurado: ID {$tenantId}\n";
echo "Dominio: {$options['domain']}\n";
echo "Database: {$options['database']}\n";
echo "\nConfigurar DNS:\n";
echo "{$options['domain']} → CNAME → portal.bautista.com\n";
echo "\nGenerar SSL:\n";
echo "certbot certonly -d {$options['domain']}\n";

Pasos Manuales

  1. Alta en tenant_domains:

    bash
    php configure-tenant.php \
      --sistema-id=5 \
      --domain=ctacte.empresaA.com.ar \
      --database=empresa_a
  2. Configurar DNS:

    ctacte.empresaA.com.ar → CNAME → portal.bautista.com
  3. Generar SSL:

    bash
    certbot certonly --webroot \
      -w /var/www/Bautista/portal-clientes/out \
      -d ctacte.empresaA.com.ar
  4. Actualizar Branding (opcional):

    sql
    UPDATE tenant_domains
    SET branding_config = '{
      "app_name": "Portal Empresa A",
      "logo_url": "https://cdn.empresaA.com/logo.png",
      "primary_color": "#c41e3a"
    }'
    WHERE domain = 'ctacte.empresaA.com.ar';

Testing Multi-Tenant

Unit Tests

php
public function testResolveTenantByDomain()
{
    $middleware = new PortalAuthMiddleware($this->tenantDomainModel);

    $request = $this->createRequest('GET', 'https://ctacte.empresaA.com.ar/portal/deudas');
    $response = $middleware->process($request, $this->handler);

    $tenantContext = $request->getAttribute('tenant_context');

    $this->assertEquals('empresa_a', $tenantContext['database']);
    $this->assertEquals('public', $tenantContext['schema']);
}

Integration Tests

php
public function testMultiTenantIsolation()
{
    // Tenant A
    $clienteA = $this->createCliente(['nombre' => 'Cliente A', 'tenant' => 'empresa_a']);

    // Tenant B
    $clienteB = $this->createCliente(['nombre' => 'Cliente B', 'tenant' => 'club_xyz']);

    // Request desde Tenant A
    $response = $this->get('/portal/deudas?cliente_id=' . $clienteB->id, [
        'Host' => 'ctacte.empresaA.com.ar'
    ]);

    // Debe fallar: Cliente B no existe en Tenant A
    $this->assertEquals(404, $response->getStatusCode());
}

Limitaciones y Consideraciones

1. Cache por Dominio

Service Worker y cache del navegador deben ser aislados:

javascript
// service-worker.js
const CACHE_NAME = `portal-${location.hostname}-v1`;

2. Sesiones

Session cookies deben ser por dominio:

php
session_set_cookie_params([
    'lifetime' => 3600,
    'path' => '/',
    'domain' => '', // Cookie solo para este dominio
    'secure' => true,
    'httponly' => true,
    'samesite' => 'Strict'
]);

3. CORS

//TODO: Permitir CORS libres para */portal/ ?

Si API y frontend están en dominios diferentes:

php
// CorsMiddleware
$allowedOrigins = [
    'https://ctacte.empresaA.com.ar',
    'https://portal.clubXYZ.com',
    // Cargar desde tenant_domains
];

if (in_array($origin, $allowedOrigins)) {
    $response = $response->withHeader('Access-Control-Allow-Origin', $origin);
}

Próximos Pasos

  1. Ver database.md para schema de tenant_domains
  2. Revisar ../backend/middleware/portal-auth-middleware.md
  3. Consultar ../deployment/domains.md para configuración DNS
  4. Leer ../security/multi-tenant-isolation.md