Appearance
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 inyectadoCó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.89Permite:
ctacte.bautista.com→ Tenant por defectoempresaA.bautista.com→ Tenant AclubXYZ.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.89Branding 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
Alta en
tenant_domains:bashphp configure-tenant.php \ --sistema-id=5 \ --domain=ctacte.empresaA.com.ar \ --database=empresa_aConfigurar DNS:
ctacte.empresaA.com.ar → CNAME → portal.bautista.comGenerar SSL:
bashcertbot certonly --webroot \ -w /var/www/Bautista/portal-clientes/out \ -d ctacte.empresaA.com.arActualizar Branding (opcional):
sqlUPDATE 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
- Ver database.md para schema de
tenant_domains - Revisar ../backend/middleware/portal-auth-middleware.md
- Consultar ../deployment/domains.md para configuración DNS
- Leer ../security/multi-tenant-isolation.md