- name
- terral-architecture
- description
- Arquitectura completa de TerrAn — ERP municipal con vista 3D, gestión documental, IA y datos en tiempo real. Referencia para hardware, escalabilidad y patrones.
- version
- 2.0.0
- author
- David Antizar
- tags
- ["erp","municipal","saas","3d","gis","postgis","threejs","ai","rag","terran"]
# TerrAn — Arquitectura de Referencia
## Qué es TerrAn
**Terr**itorio + **An**tizar (+ NaN builders). ERP municipal con vista 3D donde ayuntamientos y empresas gestionan activos físicos y humanos sobre un mapa interactivo de España. Datos en tiempo real (clima, trenes, cámaras), búsqueda semántica con IA, y gestión documental completa.
## Stack tecnológico
| Capa | Tecnología | Versión | Justificación |
|---|---|---|---|
| **Backend** | Node.js + Express | 20 LTS | Rápido de desarrollar, ESM |
| **BD principal** | PostgreSQL + PostGIS | 16+ | GIS nativo, particiones, FTS |
| **Cache** | Redis | 7+ | KPIs, sesiones, posiciones |
| **Documentos** | MinIO (S3 self-hosted) | Latest | PDFs/fotos, no en BD |
| **Búsqueda semántica** | ChromaDB | Latest | Embeddings, RAG |
| **IA** | qwen3.6 vía NaN API | — | Asistente conversacional |
| **Frontend 3D** | Three.js | 0.163+ | Terreno, activos, LOD |
| **Frontend UI** | Vanilla JS + Aurora CSS | — | Estilo David, sin framework |
| **WebSocket** | ws (Node.js) | — | Real-time |
| **PDF extraction** | pdf-parse | — | Extraer texto de PDFs |
| **Deploy** | Docker + NaN Builders | — | Contenedor aislado |
| **Object Storage** | MinIO | — | Alternativa self-hosted a S3 |
## Hardware mínimo recomendado
### Desarrollo local
- 4GB RAM, 2 cores, 40GB disco
- PostgreSQL + Redis + MinIO + Node.js = ~2GB RAM total
- ChromaDB = ~500MB RAM adicional
### Producción (1 ayuntamiento pequeño-medium)
- **Servidor:** 8GB RAM, 4 cores, 200GB SSD
- **PostgreSQL:** 4GB RAM, 100GB SSD (con particiones)
- **Redis:** 1GB RAM
- **MinIO:** 50GB (documentos)
- **Node.js:** 2GB RAM (2 instancias para HA)
- **Total estimado:** ~8GB RAM, 4 cores, 200GB SSD
### Producción (multi-tenant, 10+ ayuntamientos)
- **Servidor:** 32GB RAM, 8 cores, 1TB SSD
- **PostgreSQL:** 16GB RAM (read replica)
- **Redis:** 4GB RAM (cluster)
- **MinIO:** 500GB (documentos)
- **Node.js:** 8GB RAM (4 instancias, load balancer)
- **Total estimado:** ~32GB RAM, 8 cores, 1TB SSD
## Patrones de arquitectura
### Multi-tenant
- Cada ayuntamiento = una `organizacion` con `org_id`
- TODAS las queries filtran por `org_id`
- RLS (Row Level Security) en PostgreSQL para aislamiento
- Un usuario solo ve datos de su organización
### Sistema de Permisos y RBAC (v2 — corregido tras auditoría)
⚠️ **La versión v1 del schema (CHECK constraint con 7 roles fijos + tabla `permisos` con `alcance` como VARCHAR libre) tiene estos problemas graves:**
1. **Roles fijos en CHECK no escalan a multi-tenant** — Cada ayuntamiento necesita roles distintos. No todos tienen alcalde, concejal, inspector.
2. **`permisos.alcance` como VARCHAR(100) plano** — Sin jerarquía, sin validación, se rompe silenciosamente con typos.
3. **Sin herencia de permisos** — Un `operario` de policía no hereda "ver activos" de su rol ni de su departamento.
4. **Sin distinción VER vs EDITAR** — Un alcalde debe poder ver todo pero NO editar salarios ni crear usuarios.
5. **Sin RLS real** — El filtrado está en middleware Node.js, no en PostgreSQL. Un bug en la API expone todos los datos.
6. **Sin lock release automático** — Si un usuario abre un activo, lo bloquea con optimistic locking, y se va → el activo queda bloqueado para siempre.
#### Arquitectura RBAC corregida (3 capas)
```
Capa 1: PLATAFORMA
┌─────────────────────────────────┐
│ superadmin │
│ • Gestiona toda la plataforma │
│ • Crear organizaciones │
│ • Configurar tiers/precios │
│ • Ver logs de todas las orgs │
└─────────────────────────────────┘
Capa 2: ORGANIZACIÓN (por tenant)
┌─────────────────────────────────┐
│ admin_org (1 por organización) │
│ • Puede TODO en su org │
│ • Crear/editar/eliminar │
│ CUALQUIER recurso │
│ • Gestionar usuarios locales │
│ • Definir roles personalizados │
│ • Ver audit logs de su org │
│ • Override de optimistic locks │
├─────────────────────────────────┤
│ roles_personalizados │
│ (cada organización define │
│ sus propios roles con │
│ permisos asignables) │
└─────────────────────────────────┘
Capa 3: DATOS
┌─────────────────────────────────┐
│ RLS en PostgreSQL (obligatorio)│
│ • org_id en TODAS las queries │
│ • RLS policies por tabla │
│ • current_setting('app.*') │
└─────────────────────────────────┘
```
#### Schema de permisos corregido
```sql
-- ============================================
-- ROLES: ya no son CHECK fijo, son por organización
-- ============================================
CREATE TABLE roles_organizacion (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL REFERENCES organizaciones(id),
nombre VARCHAR(100) NOT NULL, -- 'alcalde', 'concejal', 'inspector_medioambiental'
nivel INTEGER NOT NULL DEFAULT 0, -- 0=admin, 10=directivo, 50=operario, 99=lectura
hereda_de UUID REFERENCES roles_organizacion(id), -- Herencia: operario hereda de lectura
es_admin BOOLEAN DEFAULT false, -- Si true, puede overridear TODO
activo BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT now(),
UNIQUE (org_id, nombre)
);
-- ============================================
-- PERMISOS: estructurados, no VARCHAR mágico
-- ============================================
CREATE TABLE permisos_rol (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
rol_id UUID NOT NULL REFERENCES roles_organizacion(id),
-- Qué recurso
recurso_categoria VARCHAR(100) NOT NULL, -- 'contenedor', 'ambulancia', 'doctor', 'usuario', 'documento'
-- Niveles de permiso (jerárquicos: READ < WRITE < DELETE < ADMIN)
permiso_nivel VARCHAR(20) NOT NULL CHECK (permiso_nivel IN (
'none', -- Sin acceso (explícito)
'read', -- Solo ver
'write', -- Ver + crear + editar (implica read)
'delete', -- Ver + editar + borrar (implica write)
'admin' -- Todo + conceder a otros (implica delete)
)),
-- Alcance geográfico/departamental
alcance_tipo VARCHAR(30) NOT NULL CHECK (alcance_tipo IN (
'global', -- Todo el municipio
'departamento', -- 'policia', 'sanidad', 'urbanismo'
'zona', -- 'centro', 'norte', 'poligono_industrial'
'propio' -- Solo activos que creó o tiene asignados
)),
alcance_valor VARCHAR(100), -- Valor concreto: 'policia', 'centro', etc. NULL si alcance_tipo = 'global'
created_at TIMESTAMPTZ DEFAULT now(),
UNIQUE (rol_id, recurso_categoria, alcance_tipo, COALESCE(alcance_valor, '__global__'))
);
-- ============================================
-- PERMISOS ESPECIALES (ad-hoc, para excepciones)
-- ============================================
CREATE TABLE permisos_usuario (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
usuario_id UUID NOT NULL REFERENCES usuarios(id),
recurso_categoria VARCHAR(100) NOT NULL,
permiso_nivel VARCHAR(20) NOT NULL CHECK (permiso_nivel IN ('none', 'read', 'write', 'delete', 'admin')),
alcance_tipo VARCHAR(30) NOT NULL,
alcance_valor VARCHAR(100),
granted_by UUID NOT NULL REFERENCES usuarios(id),
expira_en TIMESTAMPTZ, -- NULL = permanente
motivo TEXT,
created_at TIMESTAMPTZ DEFAULT now(),
UNIQUE (usuario_id, recurso_categoria, alcance_tipo, COALESCE(alcance_valor, '__global__'))
);
-- ============================================
-- RLS: aislamiento REAL en PostgreSQL
-- ============================================
-- Función helper
CREATE OR REPLACE FUNCTION app_current_org_id() RETURNS UUID AS $$
SELECT NULLIF(current_setting('app.org_id', true), '')::UUID;
$$ LANGUAGE SQL STABLE;
CREATE OR REPLACE FUNCTION app_current_user_id() RETURNS UUID AS $$
SELECT NULLIF(current_setting('app.user_id', true), '')::UUID;
$$ LANGUAGE SQL STABLE;
CREATE OR REPLACE FUNCTION app_is_admin() RETURNS BOOLEAN AS $$
SELECT current_setting('app.is_admin', true) = 'true';
$$ LANGUAGE SQL STABLE;
-- RLS en activos
ALTER TABLE activos ENABLE ROW LEVEL SECURITY;
-- Policy base: solo activos de mi organización
CREATE POLICY activos_org ON activos
FOR ALL USING (org_id = app_current_org_id());
-- Policy para admin: puede ver/editar todo en su org
CREATE POLICY activos_admin ON activos
FOR ALL USING (app_is_admin() AND org_id = app_current_org_id())
WITH CHECK (true);
-- Policy para operario con alcance de zona
CREATE POLICY activos_zona ON activos
FOR ALL USING (
org_id = app_current_org_id()
AND (
-- El usuario tiene alcance_zona = valor de activos.zona
EXISTS (
SELECT 1 FROM permisos_usuario pu
JOIN usuarios u ON u.id = pu.usuario_id
WHERE u.id = app_current_user_id()
AND pu.alcance_tipo = 'zona'
AND (activos.metadata->>'zona') = pu.alcance_valor
AND pu.permiso_nivel IN ('write', 'delete', 'admin')
)
OR app_is_admin()
)
);
-- ============================================
-- LOCK RELEASE AUTOMÁTICO
-- ============================================
ALTER TABLE activos ADD COLUMN IF NOT EXISTS locked_by UUID REFERENCES usuarios(id);
ALTER TABLE activos ADD COLUMN IF NOT EXISTS locked_at TIMESTAMPTZ;
-- Función de liberación automática (ejecutar cada 5 min vía cron)
CREATE OR REPLACE FUNCTION release_stale_locks() RETURNS INTEGER AS $$
DECLARE
released INTEGER;
BEGIN
UPDATE activos SET locked_by = NULL, locked_at = NULL
WHERE locked_at IS NOT NULL
AND locked_at < now() - INTERVAL '10 minutes'
AND deleted_at IS NULL;
GET DIAGNOSTICS released = ROW_COUNT;
RETURN released;
END;
$$ LANGUAGE plpgsql;
```
#### Middleware de autorización (Node.js)
```javascript
// middleware/rls-context.js
// Antes de cada request, inyecta el contexto en PostgreSQL para RLS
async function setRLSContext(req, res, next) {
if (req.user) {
await pool.query(`SELECT set_config('app.org_id', $1, true)`, [req.user.org_id]);
await pool.query(`SELECT set_config('app.user_id', $1, true)`, [req.user.id]);
await pool.query(`SELECT set_config('app.is_admin', $1, true)`,
[req.user.es_admin ? 'true' : 'false']);
}
next();
}
// middleware/permission-check.js
// Verificación adicional a nivel de aplicación (no solo RLS)
function requirePermission(categoria, nivelMinimo, alcanceTipo = null) {
return async (req, res, next) => {
if (req.user.es_admin) return next(); // ADMIN puede TODO
const tienePermiso = await checkPermission(
req.user.id, categoria, nivelMinimo, alcanceTipo, req.params.zona
);
if (!tienePermiso) {
return res.status(403).json({
error: 'permiso_denegado',
message: `No tienes permiso para ${accionNivel(nivelMinimo)} ${categoria}`,
required: { categoria, nivelMinimo, alcance: alcanceTipo }
});
}
next();
};
}
```
#### Verificación de permisos en Node.js
```javascript
// services/permission-query.js
async function checkPermission(userId, categoria, nivelMinimo, alcanceTipo, alcanceValor) {
const niveles = { 'none': 0, 'read': 1, 'write': 2, 'delete': 3, 'admin': 4 };
const minNivel = niveles[nivelMinimo];
عرض على GitHub