workspace-v1/projects/erp-suite/docs/MULTI-TENANCY.md
rckrdmrd 66161b1566 feat: Workspace-v1 complete migration with NEXUS v3.4
Sistema NEXUS v3.4 migrado con:

Estructura principal:
- core/orchestration: Sistema SIMCO + CAPVED (27 directivas, 28 perfiles)
- core/catalog: Catalogo de funcionalidades reutilizables
- shared/knowledge-base: Base de conocimiento compartida
- devtools/scripts: Herramientas de desarrollo
- control-plane/registries: Control de servicios y CI/CD
- orchestration/: Configuracion de orquestacion de agentes

Proyectos incluidos (11):
- gamilit (submodule -> GitHub)
- trading-platform (OrbiquanTIA)
- erp-suite con 5 verticales:
  - erp-core, construccion, vidrio-templado
  - mecanicas-diesel, retail, clinicas
- betting-analytics
- inmobiliaria-analytics
- platform_marketing_content
- pos-micro, erp-basico

Configuracion:
- .gitignore completo para Node.js/Python/Docker
- gamilit como submodule (git@github.com:rckrdmrd/gamilit-workspace.git)
- Sistema de puertos estandarizado (3005-3199)

Generated with NEXUS v3.4 Migration System
EPIC-010: Configuracion Git y Repositorios
2026-01-04 03:37:42 -06:00

18 KiB

Multi-Tenancy Guide

Overview

ERP Suite implementa multi-tenancy a nivel de schema usando PostgreSQL Row-Level Security (RLS) para garantizar aislamiento completo de datos entre tenants.

Modelo: Shared database, shared schema, isolated by tenant_id

Architecture

Tenant Isolation Strategy

┌─────────────────────────────────────────┐
│         Single PostgreSQL Database      │
│                                          │
│  ┌────────────────────────────────────┐ │
│  │  Table: products.products          │ │
│  │  ┌──────────────────────────────┐  │ │
│  │  │ Tenant A rows (tenant_id=A)  │  │ │
│  │  ├──────────────────────────────┤  │ │
│  │  │ Tenant B rows (tenant_id=B)  │  │ │
│  │  ├──────────────────────────────┤  │ │
│  │  │ Tenant C rows (tenant_id=C)  │  │ │
│  │  └──────────────────────────────┘  │ │
│  │                                    │ │
│  │  RLS Policy: WHERE tenant_id =    │ │
│  │    current_setting('app.current_   │ │
│  │    tenant_id')::uuid               │ │
│  └────────────────────────────────────┘ │
└─────────────────────────────────────────┘

Why RLS (Row-Level Security)?

Advantages:

  • Security at database level - Even if application has bugs, data is isolated
  • Transparent to application - No manual filtering in every query
  • Works with BI tools - Reports automatically scoped to tenant
  • Audit trail - PostgreSQL logs enforce tenant context

Disadvantages:

  • PostgreSQL specific (not portable to MySQL/MongoDB)
  • Slight performance overhead (minimal)
  • Requires SET LOCAL on each connection

Implementation

1. Database Schema Design

Tenant Table

-- Schema: auth
CREATE TABLE auth.tenants (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name VARCHAR(255) NOT NULL,
    slug VARCHAR(100) NOT NULL UNIQUE,
    status VARCHAR(20) NOT NULL DEFAULT 'active',
    plan VARCHAR(50) NOT NULL DEFAULT 'free',
    settings JSONB DEFAULT '{}',
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    deleted_at TIMESTAMPTZ
);

-- Index
CREATE INDEX idx_tenants_slug ON auth.tenants(slug) WHERE deleted_at IS NULL;
CREATE INDEX idx_tenants_status ON auth.tenants(status);

User-Tenant Relationship

CREATE TABLE auth.users (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL REFERENCES auth.tenants(id),
    email VARCHAR(255) NOT NULL,
    password_hash VARCHAR(255) NOT NULL,
    -- ...
    CONSTRAINT uq_user_email_tenant UNIQUE (email, tenant_id)
);

CREATE INDEX idx_users_tenant_id ON auth.users(tenant_id);

Standard Table Structure

Every table (except auth.tenants) must have:

CREATE TABLE {schema}.{table} (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL REFERENCES auth.tenants(id),

    -- Business columns
    -- ...

    -- Audit columns
    created_by UUID REFERENCES auth.users(id),
    updated_by UUID REFERENCES auth.users(id),
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    deleted_at TIMESTAMPTZ  -- Soft delete
);

-- Standard indexes
CREATE INDEX idx_{table}_tenant_id ON {schema}.{table}(tenant_id);
CREATE INDEX idx_{table}_deleted_at ON {schema}.{table}(deleted_at);

2. Row-Level Security Policies

Enable RLS

ALTER TABLE {schema}.{table} ENABLE ROW LEVEL SECURITY;

Standard Policy

-- Policy: tenant_isolation
-- Applies to: SELECT, INSERT, UPDATE, DELETE
CREATE POLICY tenant_isolation ON {schema}.{table}
    USING (tenant_id = current_setting('app.current_tenant_id')::uuid);

What this does:

  • USING clause filters SELECT queries
  • Also applies to UPDATE and DELETE
  • INSERT requires tenant_id to match

Super Admin Bypass (Optional)

-- Allow super admins to see all data
CREATE POLICY admin_bypass ON {schema}.{table}
    USING (
        current_setting('app.current_tenant_id')::uuid = tenant_id
        OR
        current_setting('app.is_super_admin', true)::boolean = true
    );

Example: Full RLS Setup

-- Create table
CREATE TABLE products.products (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL REFERENCES auth.tenants(id),
    name VARCHAR(255) NOT NULL,
    sku VARCHAR(100),
    price NUMERIC(12, 2),
    created_by UUID REFERENCES auth.users(id),
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    deleted_at TIMESTAMPTZ
);

-- Indexes
CREATE INDEX idx_products_tenant_id ON products.products(tenant_id);
CREATE INDEX idx_products_sku ON products.products(tenant_id, sku) WHERE deleted_at IS NULL;

-- Enable RLS
ALTER TABLE products.products ENABLE ROW LEVEL SECURITY;

-- Create policy
CREATE POLICY tenant_isolation ON products.products
    USING (tenant_id = current_setting('app.current_tenant_id')::uuid);

-- Grant permissions
GRANT SELECT, INSERT, UPDATE, DELETE ON products.products TO erp_app_user;

3. Backend Implementation

Middleware: Set Tenant Context

// middleware/tenant-context.middleware.ts
import { Request, Response, NextFunction } from 'express';
import pool from '../config/database';

export const setTenantContext = async (
  req: Request,
  res: Response,
  next: NextFunction
) => {
  const tenantId = req.user?.tenantId; // From JWT

  if (!tenantId) {
    return res.status(401).json({ error: 'Tenant not found' });
  }

  try {
    // Set tenant context for this connection
    await pool.query(
      `SET LOCAL app.current_tenant_id = $1`,
      [tenantId]
    );

    // Optional: Set super admin flag
    if (req.user?.role === 'super_admin') {
      await pool.query(`SET LOCAL app.is_super_admin = true`);
    }

    next();
  } catch (error) {
    console.error('Error setting tenant context:', error);
    res.status(500).json({ error: 'Internal server error' });
  }
};

Apply to all routes:

// app.ts
import express from 'express';
import { authenticateJWT } from './middleware/auth.middleware';
import { setTenantContext } from './middleware/tenant-context.middleware';

const app = express();

// Apply to all routes after authentication
app.use(authenticateJWT);
app.use(setTenantContext);

// Now all routes are tenant-scoped
app.use('/api/products', productRoutes);

BaseService with Tenant Enforcement

// shared/services/base.service.ts
export abstract class BaseService<T, CreateDto, UpdateDto> {
  constructor(
    protected tableName: string,
    protected schema: string
  ) {}

  async findAll(
    tenantId: string,
    filters?: Filters,
    pagination?: Pagination
  ): Promise<PaginatedResult<T>> {
    const { page = 1, limit = 20 } = pagination || {};
    const offset = (page - 1) * limit;

    // RLS automatically filters by tenant_id
    // No need to add WHERE tenant_id = $1
    const query = `
      SELECT *
      FROM ${this.schema}.${this.tableName}
      WHERE deleted_at IS NULL
      ORDER BY created_at DESC
      LIMIT $1 OFFSET $2
    `;

    const result = await pool.query(query, [limit, offset]);

    return {
      data: result.rows,
      total: result.rowCount,
      page,
      limit
    };
  }

  async create(
    data: CreateDto,
    tenantId: string,
    userId: string
  ): Promise<T> {
    const columns = Object.keys(data);
    const values = Object.values(data);

    // Explicitly add tenant_id
    columns.push('tenant_id', 'created_by');
    values.push(tenantId, userId);

    const placeholders = values.map((_, i) => `$${i + 1}`).join(', ');

    const query = `
      INSERT INTO ${this.schema}.${this.tableName} (${columns.join(', ')})
      VALUES (${placeholders})
      RETURNING *
    `;

    const result = await pool.query(query, values);
    return result.rows[0];
  }

  // Other CRUD methods...
}

4. Frontend Implementation

Store Tenant Info in Auth State

// stores/auth.store.ts
import create from 'zustand';

interface User {
  id: string;
  email: string;
  tenantId: string;
  tenantName: string;
  role: string;
}

interface AuthState {
  user: User | null;
  token: string | null;
  login: (email: string, password: string) => Promise<void>;
  logout: () => void;
}

export const useAuthStore = create<AuthState>((set) => ({
  user: null,
  token: null,

  login: async (email, password) => {
    const response = await fetch('/api/auth/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email, password })
    });

    const { user, token } = await response.json();

    set({ user, token });
    localStorage.setItem('token', token);
  },

  logout: () => {
    set({ user: null, token: null });
    localStorage.removeItem('token');
  }
}));

Display Tenant Context

// components/TenantIndicator.tsx
import { useAuthStore } from '../stores/auth.store';

export const TenantIndicator = () => {
  const user = useAuthStore((state) => state.user);

  if (!user) return null;

  return (
    <div className="bg-blue-100 px-4 py-2 rounded">
      <span className="text-sm text-gray-700">
        Tenant: <strong>{user.tenantName}</strong>
      </span>
    </div>
  );
};

Multi-Tenant SaaS Features

Onboarding New Tenant

// services/tenant.service.ts
export class TenantService {
  async createTenant(data: CreateTenantDto): Promise<Tenant> {
    const client = await pool.connect();

    try {
      await client.query('BEGIN');

      // 1. Create tenant
      const tenantQuery = `
        INSERT INTO auth.tenants (name, slug, plan)
        VALUES ($1, $2, $3)
        RETURNING *
      `;
      const tenantResult = await client.query(tenantQuery, [
        data.name,
        data.slug,
        data.plan || 'free'
      ]);
      const tenant = tenantResult.rows[0];

      // 2. Create admin user for tenant
      const passwordHash = await bcrypt.hash(data.adminPassword, 10);
      const userQuery = `
        INSERT INTO auth.users (tenant_id, email, password_hash, role)
        VALUES ($1, $2, $3, $4)
        RETURNING *
      `;
      await client.query(userQuery, [
        tenant.id,
        data.adminEmail,
        passwordHash,
        'admin'
      ]);

      // 3. Initialize default data (catalogs, etc.)
      await this.initializeTenantData(client, tenant.id);

      await client.query('COMMIT');
      return tenant;

    } catch (error) {
      await client.query('ROLLBACK');
      throw error;
    } finally {
      client.release();
    }
  }

  private async initializeTenantData(client: PoolClient, tenantId: string) {
    // Create default categories
    await client.query(`
      INSERT INTO products.categories (tenant_id, name)
      VALUES
        ($1, 'General'),
        ($1, 'Services')
    `, [tenantId]);

    // Create default warehouse
    await client.query(`
      INSERT INTO inventory.warehouses (tenant_id, name, code)
      VALUES ($1, 'Main Warehouse', 'WH01')
    `, [tenantId]);

    // More defaults...
  }
}

Tenant Switching (for super admins)

// middleware/switch-tenant.middleware.ts
export const switchTenant = async (
  req: Request,
  res: Response,
  next: NextFunction
) => {
  const targetTenantId = req.headers['x-tenant-id'] as string;

  if (req.user?.role !== 'super_admin') {
    return res.status(403).json({ error: 'Only super admins can switch tenants' });
  }

  if (!targetTenantId) {
    return res.status(400).json({ error: 'Target tenant ID required' });
  }

  // Override tenant context
  await pool.query(`SET LOCAL app.current_tenant_id = $1`, [targetTenantId]);
  next();
};

Data Isolation Testing

Test Suite

// __tests__/multi-tenancy.test.ts
import { pool } from '../config/database';
import { TenantService } from '../services/tenant.service';
import { ProductService } from '../modules/products/product.service';

describe('Multi-Tenancy Data Isolation', () => {
  let tenantA: Tenant;
  let tenantB: Tenant;

  beforeAll(async () => {
    tenantA = await TenantService.createTenant({ name: 'Tenant A', ... });
    tenantB = await TenantService.createTenant({ name: 'Tenant B', ... });
  });

  it('should isolate data between tenants', async () => {
    // Set context to Tenant A
    await pool.query(`SET LOCAL app.current_tenant_id = $1`, [tenantA.id]);

    // Create product for Tenant A
    const productA = await ProductService.create({
      name: 'Product A',
      price: 100
    }, tenantA.id, 'user-a');

    // Switch context to Tenant B
    await pool.query(`SET LOCAL app.current_tenant_id = $1`, [tenantB.id]);

    // Try to fetch products (should only see Tenant B's data)
    const products = await ProductService.findAll(tenantB.id);

    expect(products.data).toHaveLength(0); // Tenant B has no products
    expect(products.data).not.toContainEqual(productA);
  });

  it('should prevent cross-tenant access', async () => {
    // Set context to Tenant A
    await pool.query(`SET LOCAL app.current_tenant_id = $1`, [tenantA.id]);

    // Create product for Tenant A
    const productA = await ProductService.create({
      name: 'Product A'
    }, tenantA.id, 'user-a');

    // Switch to Tenant B
    await pool.query(`SET LOCAL app.current_tenant_id = $1`, [tenantB.id]);

    // Try to access Tenant A's product
    const result = await ProductService.findById(productA.id, tenantB.id);

    expect(result).toBeNull(); // RLS blocks access
  });
});

Performance Considerations

Indexing Strategy

Always index tenant_id:

CREATE INDEX idx_{table}_tenant_id ON {schema}.{table}(tenant_id);

Composite indexes for common queries:

-- Query: Find product by SKU for tenant
CREATE INDEX idx_products_tenant_sku
ON products.products(tenant_id, sku)
WHERE deleted_at IS NULL;

-- Query: List recent orders for tenant
CREATE INDEX idx_orders_tenant_created
ON sales.orders(tenant_id, created_at DESC);

Query Performance

With RLS:

EXPLAIN ANALYZE
SELECT * FROM products.products WHERE sku = 'ABC123';

-- Plan:
-- Index Scan using idx_products_tenant_sku
-- Filter: (tenant_id = '...'::uuid)  ← Automatic

RLS overhead: ~5-10% (minimal with proper indexing)

Connection Pooling

// config/database.ts
import { Pool } from 'pg';

export const pool = new Pool({
  host: process.env.DB_HOST,
  port: parseInt(process.env.DB_PORT || '5432'),
  database: process.env.DB_NAME,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  max: 20,  // Max connections
  idleTimeoutMillis: 30000,
  connectionTimeoutMillis: 2000,
});

// Set tenant context on each query
pool.on('connect', async (client) => {
  // This is set per-request, not per-connection
  // Use middleware instead
});

Security Best Practices

1. Always Validate Tenant Access

// Even with RLS, validate user belongs to tenant
if (req.user.tenantId !== req.params.tenantId) {
  return res.status(403).json({ error: 'Forbidden' });
}

2. Never Disable RLS

-- ❌ NEVER DO THIS
ALTER TABLE products.products NO FORCE ROW LEVEL SECURITY;

3. Audit Tenant Changes

CREATE TABLE audit.tenant_changes (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL,
    user_id UUID NOT NULL,
    action VARCHAR(50) NOT NULL,
    table_name VARCHAR(100) NOT NULL,
    record_id UUID,
    old_data JSONB,
    new_data JSONB,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

4. Monitor Cross-Tenant Attempts

// Log suspicious activity
if (attemptedCrossTenantAccess) {
  logger.warn('Cross-tenant access attempt', {
    userId: req.user.id,
    userTenant: req.user.tenantId,
    targetTenant: req.params.tenantId,
    ip: req.ip
  });
}

Troubleshooting

RLS Not Working

Check if RLS is enabled:

SELECT tablename, rowsecurity
FROM pg_tables
WHERE schemaname = 'products';

Check policies:

SELECT * FROM pg_policies WHERE tablename = 'products';

Verify tenant context is set:

SHOW app.current_tenant_id;

Performance Issues

Check query plan:

EXPLAIN ANALYZE
SELECT * FROM products.products WHERE name LIKE '%widget%';

Add missing indexes:

CREATE INDEX idx_products_name ON products.products(tenant_id, name);

References