erp-core/docs/04-modelado/especificaciones-tecnicas/transversal/SPEC-SEGURIDAD-API-KEYS-PERMISOS.md

43 KiB

Especificación Técnica: Sistema de Seguridad - API Keys y Permisos Granulares

Código: SPEC-TRANS-003 Versión: 1.0 Fecha: 2025-12-08 Estado: Especificado Basado en: Odoo res.users.apikeys, ir.model.access, ir.rule (v18.0)


1. Resumen Ejecutivo

1.1 Propósito

Este documento especifica el sistema de seguridad multi-capa del ERP que incluye:

  • API Keys: Autenticación para integraciones externas y APIs
  • Access Control Lists (ACL): Permisos CRUD a nivel de modelo
  • Record Rules: Seguridad a nivel de registro (row-level security)
  • Field Permissions: Control de visibilidad a nivel de campo

1.2 Alcance

  • Generación y gestión de API Keys con hash seguro
  • Sistema de scopes para limitar acceso de APIs
  • Permisos CRUD por modelo y grupo de usuarios
  • Reglas dinámicas basadas en dominio para filtrar registros
  • Permisos a nivel de campo por grupo de usuarios

1.3 Módulos Afectados

Módulo Rol
MGN-001 (Auth) API Keys, autenticación
MGN-002 (Users) Usuarios y grupos
MGN-003 (Roles) Permisos y ACL
Todos Aplicación de reglas de acceso

2. Arquitectura de Seguridad

2.1 Modelo de Capas

┌─────────────────────────────────────────────────────────────────┐
│                    FLUJO DE AUTORIZACIÓN                         │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│  1. AUTENTICACIÓN                                               │
│  ┌───────────────────────────────────────────────────────────┐  │
│  │  • Password + MFA (usuarios interactivos)                 │  │
│  │  • API Key + Scope (integraciones)                        │  │
│  │  • JWT Token (sesiones activas)                           │  │
│  └───────────────────────────────────────────────────────────┘  │
│                          │                                       │
│                          v                                       │
│  2. RESOLUCIÓN DE GRUPOS                                        │
│  ┌───────────────────────────────────────────────────────────┐  │
│  │  user.groups = [group_admin, group_sales_manager, ...]    │  │
│  │  Herencia: group_sales_manager implica group_sales_user   │  │
│  └───────────────────────────────────────────────────────────┘  │
│                          │                                       │
│                          v                                       │
│  3. ACCESS CONTROL LIST (ACL)                                   │
│  ┌───────────────────────────────────────────────────────────┐  │
│  │  ¿Tiene el usuario (vía grupos) permiso CRUD en modelo?   │  │
│  │  Tabla: model_access (model_id, group_id, perm_read...)   │  │
│  │  Si NO tiene permiso → AccessDenied                       │  │
│  └───────────────────────────────────────────────────────────┘  │
│                          │                                       │
│                          v                                       │
│  4. RECORD RULES (Row-Level Security)                           │
│  ┌───────────────────────────────────────────────────────────┐  │
│  │  Filtrar registros según dominio dinámico                 │  │
│  │  Ejemplo: [('company_id', 'in', user.company_ids)]        │  │
│  │  Reglas globales: AND entre todas                         │  │
│  │  Reglas de grupo: OR entre grupos del usuario             │  │
│  └───────────────────────────────────────────────────────────┘  │
│                          │                                       │
│                          v                                       │
│  5. FIELD PERMISSIONS                                           │
│  ┌───────────────────────────────────────────────────────────┐  │
│  │  Campos restringidos a grupos específicos                 │  │
│  │  Campo sin permiso → NULL / oculto en respuesta           │  │
│  └───────────────────────────────────────────────────────────┘  │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘

2.2 Principios de Diseño

  1. Defense in Depth: Múltiples capas de seguridad
  2. Least Privilege: Permisos mínimos por defecto
  3. Explicit Deny: Sin permiso explícito = denegado
  4. Audit Trail: Registro de accesos y cambios de permisos

3. API Keys

3.1 Modelo de Datos

CREATE TABLE core_auth.api_keys (
    -- Identificación
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULL REFERENCES core_auth.users(id) ON DELETE CASCADE,
    tenant_id UUID NOT NULL,

    -- Descripción
    name VARCHAR(255) NOT NULL,                -- Descripción del propósito

    -- Seguridad
    key_index VARCHAR(16) NOT NULL,            -- Primeros 8 bytes del key (para lookup rápido)
    key_hash VARCHAR(255) NOT NULL,            -- Hash PBKDF2-SHA512 del key completo

    -- Scope y restricciones
    scope VARCHAR(100),                         -- NULL = acceso completo, 'rpc' = solo API
    allowed_ips INET[],                         -- IPs permitidas (opcional)

    -- Expiración
    expiration_date TIMESTAMPTZ,               -- NULL = sin expiración (solo system users)
    last_used_at TIMESTAMPTZ,                  -- Último uso

    -- Auditoría
    is_active BOOLEAN NOT NULL DEFAULT TRUE,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    revoked_at TIMESTAMPTZ,
    revoked_by UUID REFERENCES core_auth.users(id),

    -- Constraints
    CONSTRAINT chk_key_index_length CHECK (LENGTH(key_index) = 16)
);

-- Índice para búsqueda por index (usado en autenticación)
CREATE INDEX idx_api_keys_lookup ON core_auth.api_keys (key_index, is_active)
WHERE is_active = TRUE;

-- Índice para limpieza de expirados
CREATE INDEX idx_api_keys_expiration ON core_auth.api_keys (expiration_date)
WHERE expiration_date IS NOT NULL;

-- Índice por usuario
CREATE INDEX idx_api_keys_user ON core_auth.api_keys (user_id);

COMMENT ON TABLE core_auth.api_keys IS 'API Keys para autenticación de integraciones externas';
COMMENT ON COLUMN core_auth.api_keys.key_index IS 'Primeros 16 hex chars del key para lookup O(1)';
COMMENT ON COLUMN core_auth.api_keys.key_hash IS 'Hash PBKDF2-SHA512 del key completo';
COMMENT ON COLUMN core_auth.api_keys.scope IS 'Scope del API key (NULL=full, rpc=API only)';

3.2 Configuración de Duración por Grupo

-- Agregar campo a grupos para límite de duración
ALTER TABLE core_auth.groups ADD COLUMN IF NOT EXISTS
    api_key_max_duration_days INTEGER DEFAULT 30
    CHECK (api_key_max_duration_days >= 0);  -- 0 = sin expiración (solo grupos system)

COMMENT ON COLUMN core_auth.groups.api_key_max_duration_days IS
'Máxima duración en días para API keys de usuarios de este grupo (0=ilimitado)';

3.3 Generación y Verificación de API Keys

// Constantes de seguridad
const API_KEY_SIZE = 20;        // 20 bytes = 40 hex characters
const KEY_INDEX_SIZE = 8;       // 8 bytes = 16 hex characters (índice de búsqueda)
const PBKDF2_ITERATIONS = 6000; // Menos que password (keys son aleatorios)
const PBKDF2_ALGORITHM = 'sha512';

interface ApiKeyGenerationResult {
  keyId: UUID;
  apiKey: string;  // Solo se muestra una vez
  expiresAt: Date | null;
}

interface ApiKeyValidationResult {
  valid: boolean;
  userId?: UUID;
  scope?: string;
  authMethod: 'apikey';
}

class ApiKeyService {

  /**
   * Genera un nuevo API Key para un usuario
   */
  async generateApiKey(
    userId: UUID,
    name: string,
    options: {
      scope?: string;
      expirationDays?: number;
      allowedIps?: string[];
    }
  ): Promise<ApiKeyGenerationResult> {

    // 1. Obtener límite de duración del grupo del usuario
    const maxDuration = await this.getMaxDurationForUser(userId);

    // 2. Validar duración solicitada
    const requestedDays = options.expirationDays ?? maxDuration;
    if (maxDuration > 0 && requestedDays > maxDuration) {
      throw new ValidationError(
        `Duración máxima permitida: ${maxDuration} días`
      );
    }

    // 3. Generar key aleatorio criptográficamente seguro
    const keyBytes = crypto.randomBytes(API_KEY_SIZE);
    const apiKey = keyBytes.toString('hex');  // 40 caracteres hex

    // 4. Extraer índice (primeros 16 hex = 8 bytes)
    const keyIndex = apiKey.substring(0, KEY_INDEX_SIZE * 2);

    // 5. Hashear el key completo
    const keyHash = await this.hashApiKey(apiKey);

    // 6. Calcular fecha de expiración
    const expiresAt = requestedDays > 0
      ? new Date(Date.now() + requestedDays * 24 * 60 * 60 * 1000)
      : null;  // Sin expiración

    // 7. Guardar en base de datos
    const keyRecord = await db.query(`
      INSERT INTO core_auth.api_keys (
        user_id, tenant_id, name, key_index, key_hash,
        scope, allowed_ips, expiration_date
      ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
      RETURNING id
    `, [
      userId, tenantId, name, keyIndex, keyHash,
      options.scope, options.allowedIps, expiresAt
    ]);

    // 8. Retornar key (única vez que se muestra)
    return {
      keyId: keyRecord.id,
      apiKey: apiKey,  // ¡Usuario debe guardarlo ahora!
      expiresAt
    };
  }

  /**
   * Valida un API Key y retorna el usuario asociado
   */
  async validateApiKey(
    apiKey: string,
    scope: string,
    clientIp?: string
  ): Promise<ApiKeyValidationResult> {

    // 1. Extraer índice para búsqueda rápida
    if (apiKey.length !== API_KEY_SIZE * 2) {
      return { valid: false, authMethod: 'apikey' };
    }
    const keyIndex = apiKey.substring(0, KEY_INDEX_SIZE * 2);

    // 2. Buscar candidatos por índice
    const candidates = await db.query(`
      SELECT id, user_id, key_hash, scope, allowed_ips, expiration_date
      FROM core_auth.api_keys
      WHERE key_index = $1
        AND is_active = TRUE
        AND (expiration_date IS NULL OR expiration_date > NOW())
        AND (scope IS NULL OR scope = $2)
    `, [keyIndex, scope]);

    // 3. Verificar hash contra cada candidato
    for (const candidate of candidates) {
      const isValid = await this.verifyApiKey(apiKey, candidate.key_hash);

      if (isValid) {
        // 4. Verificar IP si está configurado
        if (candidate.allowed_ips?.length > 0 && clientIp) {
          if (!this.isIpAllowed(clientIp, candidate.allowed_ips)) {
            continue;  // IP no permitida
          }
        }

        // 5. Verificar que usuario está activo
        const user = await this.getUserStatus(candidate.user_id);
        if (!user.isActive) {
          continue;
        }

        // 6. Actualizar last_used_at
        await db.query(`
          UPDATE core_auth.api_keys
          SET last_used_at = NOW()
          WHERE id = $1
        `, [candidate.id]);

        return {
          valid: true,
          userId: candidate.user_id,
          scope: candidate.scope,
          authMethod: 'apikey'
        };
      }
    }

    return { valid: false, authMethod: 'apikey' };
  }

  /**
   * Hash de API Key usando PBKDF2
   */
  private async hashApiKey(apiKey: string): Promise<string> {
    const salt = crypto.randomBytes(16).toString('hex');

    return new Promise((resolve, reject) => {
      crypto.pbkdf2(
        apiKey, salt, PBKDF2_ITERATIONS, 64, PBKDF2_ALGORITHM,
        (err, derivedKey) => {
          if (err) reject(err);
          // Formato: $pbkdf2-sha512$iterations$salt$hash
          resolve(`$pbkdf2-sha512$${PBKDF2_ITERATIONS}$${salt}$${derivedKey.toString('hex')}`);
        }
      );
    });
  }

  /**
   * Verifica API Key contra hash almacenado
   */
  private async verifyApiKey(apiKey: string, storedHash: string): Promise<boolean> {
    const parts = storedHash.split('$');
    // $pbkdf2-sha512$6000$salt$hash
    const iterations = parseInt(parts[2]);
    const salt = parts[3];
    const expectedHash = parts[4];

    return new Promise((resolve, reject) => {
      crypto.pbkdf2(
        apiKey, salt, iterations, 64, PBKDF2_ALGORITHM,
        (err, derivedKey) => {
          if (err) reject(err);
          resolve(crypto.timingSafeEqual(
            Buffer.from(derivedKey.toString('hex')),
            Buffer.from(expectedHash)
          ));
        }
      );
    });
  }

  /**
   * Obtiene duración máxima de API keys para un usuario
   */
  private async getMaxDurationForUser(userId: UUID): Promise<number> {
    const result = await db.queryOne(`
      SELECT COALESCE(MAX(g.api_key_max_duration_days), 30) AS max_duration
      FROM core_auth.user_groups ug
      JOIN core_auth.groups g ON ug.group_id = g.id
      WHERE ug.user_id = $1
    `, [userId]);

    return result.max_duration;
  }
}

3.4 Limpieza Automática de Keys Expirados

// Job programado (cron) para limpiar keys expirados
@Cron('0 2 * * *')  // Diariamente a las 2 AM
async cleanupExpiredApiKeys(): Promise<void> {
  const result = await db.query(`
    DELETE FROM core_auth.api_keys
    WHERE expiration_date IS NOT NULL
      AND expiration_date < NOW()
    RETURNING id, user_id, name
  `);

  if (result.length > 0) {
    logger.info(`Cleaned up ${result.length} expired API keys`);
  }
}

4. Access Control Lists (ACL)

4.1 Modelo de Datos

-- Definición de modelos del sistema
CREATE TABLE core_auth.models (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name VARCHAR(128) NOT NULL UNIQUE,         -- Nombre técnico (ej: 'sale.order')
    description VARCHAR(255),                   -- Descripción legible
    module VARCHAR(64),                         -- Módulo al que pertenece
    is_transient BOOLEAN NOT NULL DEFAULT FALSE, -- Modelo temporal
    tenant_id UUID NOT NULL,

    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_models_name ON core_auth.models (name);

-- Permisos CRUD por modelo y grupo
CREATE TABLE core_auth.model_access (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name VARCHAR(255) NOT NULL,                 -- Identificador legible

    model_id UUID NOT NULL REFERENCES core_auth.models(id) ON DELETE CASCADE,
    group_id UUID REFERENCES core_auth.groups(id) ON DELETE RESTRICT,  -- NULL = global

    -- Permisos CRUD
    perm_read BOOLEAN NOT NULL DEFAULT FALSE,
    perm_create BOOLEAN NOT NULL DEFAULT FALSE,
    perm_write BOOLEAN NOT NULL DEFAULT FALSE,
    perm_delete BOOLEAN NOT NULL DEFAULT FALSE,

    is_active BOOLEAN NOT NULL DEFAULT TRUE,
    tenant_id UUID NOT NULL,

    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),

    -- Un grupo solo puede tener un registro por modelo
    CONSTRAINT uq_model_access_model_group
        UNIQUE (model_id, group_id, tenant_id)
);

CREATE INDEX idx_model_access_model ON core_auth.model_access (model_id);
CREATE INDEX idx_model_access_group ON core_auth.model_access (group_id);

COMMENT ON TABLE core_auth.model_access IS 'Permisos CRUD a nivel de modelo por grupo';
COMMENT ON COLUMN core_auth.model_access.group_id IS 'NULL significa acceso global (todos)';

4.2 Servicio de Verificación ACL

interface AccessCheckResult {
  hasAccess: boolean;
  deniedReason?: string;
}

class ModelAccessService {

  // Cache de permisos por usuario
  private accessCache = new Map<string, Set<string>>();

  /**
   * Verifica si un usuario tiene permiso CRUD en un modelo
   */
  async checkAccess(
    userId: UUID,
    modelName: string,
    mode: 'read' | 'create' | 'write' | 'delete',
    raiseException: boolean = true
  ): Promise<AccessCheckResult> {

    // 1. Superuser bypass
    if (await this.isSuperuser(userId)) {
      return { hasAccess: true };
    }

    // 2. Verificar en cache
    const cacheKey = `${userId}:${mode}`;
    if (this.accessCache.has(cacheKey)) {
      const allowedModels = this.accessCache.get(cacheKey)!;
      if (allowedModels.has(modelName)) {
        return { hasAccess: true };
      }
    }

    // 3. Obtener grupos del usuario
    const groupIds = await this.getUserGroupIds(userId);

    // 4. Verificar permiso en base de datos
    const hasAccess = await db.queryOne(`
      SELECT EXISTS (
        SELECT 1 FROM core_auth.model_access ma
        JOIN core_auth.models m ON ma.model_id = m.id
        WHERE m.name = $1
          AND ma.is_active = TRUE
          AND ma.perm_${mode} = TRUE
          AND (ma.group_id IS NULL OR ma.group_id = ANY($2))
      ) AS has_access
    `, [modelName, groupIds]);

    // 5. Actualizar cache
    if (!this.accessCache.has(cacheKey)) {
      this.accessCache.set(cacheKey, new Set());
    }
    if (hasAccess.has_access) {
      this.accessCache.get(cacheKey)!.add(modelName);
    }

    // 6. Manejar denegación
    if (!hasAccess.has_access && raiseException) {
      const groupsWithAccess = await this.getGroupsWithAccess(modelName, mode);
      throw new AccessDeniedError({
        message: `Acceso denegado para operación '${mode}' en modelo '${modelName}'`,
        model: modelName,
        operation: mode,
        requiredGroups: groupsWithAccess
      });
    }

    return {
      hasAccess: hasAccess.has_access,
      deniedReason: hasAccess.has_access ? undefined : `No tiene permiso '${mode}' en '${modelName}'`
    };
  }

  /**
   * Obtiene todos los modelos accesibles para un usuario
   */
  async getAllowedModels(userId: UUID, mode: 'read' | 'create' | 'write' | 'delete'): Promise<string[]> {
    const groupIds = await this.getUserGroupIds(userId);

    const result = await db.query(`
      SELECT DISTINCT m.name
      FROM core_auth.model_access ma
      JOIN core_auth.models m ON ma.model_id = m.id
      WHERE ma.is_active = TRUE
        AND ma.perm_${mode} = TRUE
        AND (ma.group_id IS NULL OR ma.group_id = ANY($1))
      ORDER BY m.name
    `, [groupIds]);

    return result.map(r => r.name);
  }

  /**
   * Invalida cache de un usuario (llamar cuando cambian grupos)
   */
  invalidateUserCache(userId: UUID): void {
    for (const mode of ['read', 'create', 'write', 'delete']) {
      this.accessCache.delete(`${userId}:${mode}`);
    }
  }
}

4.3 Formato CSV para Importación

# Archivo: security/model_access.csv
id,name,model_id,group_id,perm_read,perm_create,perm_write,perm_delete
access_sale_order_manager,sale.order.manager,sale.order,group_sales_manager,1,1,1,1
access_sale_order_user,sale.order.user,sale.order,group_sales_user,1,1,1,0
access_sale_order_readonly,sale.order.readonly,sale.order,group_sales_readonly,1,0,0,0
access_product_public,product.product.public,product.product,,1,0,0,0

5. Record Rules (Row-Level Security)

5.1 Modelo de Datos

CREATE TABLE core_auth.record_rules (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name VARCHAR(255) NOT NULL,

    model_id UUID NOT NULL REFERENCES core_auth.models(id) ON DELETE CASCADE,

    -- Dominio como expresión JSON
    domain_expression JSONB NOT NULL,          -- [["company_id", "in", "user.company_ids"]]

    -- Permisos afectados
    perm_read BOOLEAN NOT NULL DEFAULT TRUE,
    perm_create BOOLEAN NOT NULL DEFAULT TRUE,
    perm_write BOOLEAN NOT NULL DEFAULT TRUE,
    perm_delete BOOLEAN NOT NULL DEFAULT TRUE,

    -- Grupos (vacío = regla global)
    is_global BOOLEAN GENERATED ALWAYS AS (
        NOT EXISTS (
            SELECT 1 FROM core_auth.rule_groups rg WHERE rg.rule_id = id
        )
    ) STORED,

    is_active BOOLEAN NOT NULL DEFAULT TRUE,
    tenant_id UUID NOT NULL,

    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Relación M:N con grupos
CREATE TABLE core_auth.rule_groups (
    rule_id UUID NOT NULL REFERENCES core_auth.record_rules(id) ON DELETE CASCADE,
    group_id UUID NOT NULL REFERENCES core_auth.groups(id) ON DELETE CASCADE,
    PRIMARY KEY (rule_id, group_id)
);

CREATE INDEX idx_record_rules_model ON core_auth.record_rules (model_id);
CREATE INDEX idx_record_rules_global ON core_auth.record_rules (is_global) WHERE is_global = TRUE;

COMMENT ON TABLE core_auth.record_rules IS 'Reglas de acceso a nivel de registro (row-level security)';
COMMENT ON COLUMN core_auth.record_rules.domain_expression IS 'Expresión de dominio JSON evaluada dinámicamente';

5.2 Contexto de Evaluación de Dominio

interface RuleEvalContext {
  user: {
    id: UUID;
    company_id: UUID;
    company_ids: UUID[];
    groups: string[];
    partner_id?: UUID;
  };
  company_id: UUID;
  company_ids: UUID[];
  today: Date;
  now: Date;
}

/**
 * Construye el contexto de evaluación para reglas de dominio
 */
function buildRuleEvalContext(userId: UUID): RuleEvalContext {
  const user = getUserWithCompanies(userId);

  return {
    user: {
      id: user.id,
      company_id: user.current_company_id,
      company_ids: user.allowed_company_ids,
      groups: user.group_codes,
      partner_id: user.partner_id
    },
    company_id: user.current_company_id,
    company_ids: user.allowed_company_ids,
    today: new Date().toISOString().split('T')[0],
    now: new Date()
  };
}

5.3 Evaluación de Dominio

type DomainOperator = '=' | '!=' | '>' | '>=' | '<' | '<=' |
                      'in' | 'not in' | 'like' | 'ilike' |
                      'child_of' | 'parent_of';

type DomainLeaf = [string, DomainOperator, any];
type DomainNode = '&' | '|' | '!';
type DomainExpression = (DomainLeaf | DomainNode)[];

class RecordRuleService {

  /**
   * Obtiene todas las reglas aplicables a un modelo y usuario
   */
  async getRulesForModel(
    modelName: string,
    mode: 'read' | 'create' | 'write' | 'delete',
    userId: UUID
  ): Promise<RecordRule[]> {

    const groupIds = await this.getUserGroupIds(userId);

    const rules = await db.query(`
      SELECT rr.id, rr.name, rr.domain_expression, rr.is_global
      FROM core_auth.record_rules rr
      JOIN core_auth.models m ON rr.model_id = m.id
      WHERE m.name = $1
        AND rr.is_active = TRUE
        AND rr.perm_${mode} = TRUE
        AND (
          rr.is_global = TRUE
          OR EXISTS (
            SELECT 1 FROM core_auth.rule_groups rg
            WHERE rg.rule_id = rr.id
              AND rg.group_id = ANY($2)
          )
        )
    `, [modelName, groupIds]);

    return rules;
  }

  /**
   * Combina dominios de reglas según lógica Odoo:
   * - Reglas globales: AND entre todas
   * - Reglas de grupo: OR entre las que aplican al usuario
   * - Final: global_domain AND group_domain
   */
  async computeDomain(
    modelName: string,
    mode: 'read' | 'create' | 'write' | 'delete',
    userId: UUID
  ): Promise<DomainExpression> {

    const rules = await this.getRulesForModel(modelName, mode, userId);
    const context = buildRuleEvalContext(userId);

    // Separar reglas globales y de grupo
    const globalRules = rules.filter(r => r.is_global);
    const groupRules = rules.filter(r => !r.is_global);

    // Evaluar dominios sustituyendo variables
    const globalDomains = globalRules.map(r =>
      this.evaluateDomainExpression(r.domain_expression, context)
    );
    const groupDomains = groupRules.map(r =>
      this.evaluateDomainExpression(r.domain_expression, context)
    );

    // Combinar: AND(globals) AND OR(groups)
    let finalDomain: DomainExpression = [];

    // AND de reglas globales
    if (globalDomains.length > 0) {
      for (const domain of globalDomains) {
        if (finalDomain.length > 0) {
          finalDomain = ['&', ...finalDomain, ...domain];
        } else {
          finalDomain = domain;
        }
      }
    }

    // OR de reglas de grupo
    if (groupDomains.length > 0) {
      let groupDomain: DomainExpression = groupDomains[0];
      for (let i = 1; i < groupDomains.length; i++) {
        groupDomain = ['|', ...groupDomain, ...groupDomains[i]];
      }

      if (finalDomain.length > 0) {
        finalDomain = ['&', ...finalDomain, ...groupDomain];
      } else {
        finalDomain = groupDomain;
      }
    }

    return finalDomain;
  }

  /**
   * Evalúa expresión de dominio sustituyendo variables del contexto
   */
  private evaluateDomainExpression(
    expression: DomainExpression,
    context: RuleEvalContext
  ): DomainExpression {
    return expression.map(item => {
      if (Array.isArray(item) && item.length === 3) {
        const [field, operator, value] = item;
        const evaluatedValue = this.evaluateValue(value, context);
        return [field, operator, evaluatedValue] as DomainLeaf;
      }
      return item;
    }) as DomainExpression;
  }

  /**
   * Evalúa un valor que puede contener referencias al contexto
   */
  private evaluateValue(value: any, context: RuleEvalContext): any {
    if (typeof value !== 'string') {
      return value;
    }

    // Patrones de sustitución: user.field, company_id, etc.
    if (value.startsWith('user.')) {
      const path = value.substring(5);  // Quitar 'user.'
      return this.getNestedValue(context.user, path);
    }

    if (value === 'company_id') {
      return context.company_id;
    }

    if (value === 'company_ids') {
      return context.company_ids;
    }

    if (value === 'today') {
      return context.today;
    }

    if (value === 'now') {
      return context.now;
    }

    return value;
  }

  private getNestedValue(obj: any, path: string): any {
    return path.split('.').reduce((current, key) => current?.[key], obj);
  }
}

5.4 Ejemplos de Record Rules

// Regla Global: Multi-empresa (aplica a todos)
{
  "name": "Multi-company access",
  "model": "sale.order",
  "domain_expression": [
    "|",
    ["company_id", "=", false],
    ["company_id", "in", "company_ids"]
  ],
  "is_global": true,
  "perm_read": true,
  "perm_write": true,
  "perm_create": true,
  "perm_delete": true
}

// Regla de Grupo: Vendedor solo ve sus propias órdenes
{
  "name": "Salesperson own orders",
  "model": "sale.order",
  "domain_expression": [
    ["user_id", "=", "user.id"]
  ],
  "groups": ["group_sales_user"],
  "perm_read": true,
  "perm_write": true,
  "perm_create": true,
  "perm_delete": false
}

// Regla de Grupo: Manager ve todas las órdenes de su equipo
{
  "name": "Sales manager team orders",
  "model": "sale.order",
  "domain_expression": [
    "|",
    ["user_id", "=", "user.id"],
    ["team_id.user_id", "=", "user.id"]
  ],
  "groups": ["group_sales_manager"],
  "perm_read": true,
  "perm_write": true,
  "perm_create": true,
  "perm_delete": true
}

5.5 Integración con Query Builder

class SecureQueryBuilder {

  /**
   * Aplica record rules al WHERE de una consulta
   */
  async buildSecureQuery(
    modelName: string,
    baseDomain: DomainExpression,
    userId: UUID,
    mode: 'read' | 'write' | 'delete' = 'read'
  ): Promise<{ where: string; params: any[] }> {

    // 1. Obtener dominio de record rules
    const ruleDomain = await this.recordRuleService.computeDomain(
      modelName, mode, userId
    );

    // 2. Combinar con dominio base
    let combinedDomain = baseDomain;
    if (ruleDomain.length > 0) {
      combinedDomain = ['&', ...baseDomain, ...ruleDomain];
    }

    // 3. Convertir a SQL WHERE
    return this.domainToSql(combinedDomain);
  }

  /**
   * Convierte expresión de dominio a SQL
   */
  private domainToSql(domain: DomainExpression): { where: string; params: any[] } {
    const params: any[] = [];
    let paramIndex = 1;

    function processNode(node: DomainLeaf | DomainNode): string {
      if (node === '&') return 'AND';
      if (node === '|') return 'OR';
      if (node === '!') return 'NOT';

      if (Array.isArray(node)) {
        const [field, operator, value] = node;
        const sqlOp = operatorToSql(operator);

        if (operator === 'in' || operator === 'not in') {
          params.push(value);
          return `"${field}" ${sqlOp} ($${paramIndex++})`;
        }

        params.push(value);
        return `"${field}" ${sqlOp} $${paramIndex++}`;
      }

      return '';
    }

    // Procesar dominio en notación polaca
    // TODO: Implementar parser completo de dominio
    const whereParts = domain.map(processNode).filter(Boolean);

    return {
      where: whereParts.join(' '),
      params
    };
  }
}

function operatorToSql(op: DomainOperator): string {
  const mapping: Record<DomainOperator, string> = {
    '=': '=',
    '!=': '!=',
    '>': '>',
    '>=': '>=',
    '<': '<',
    '<=': '<=',
    'in': '= ANY',
    'not in': '!= ALL',
    'like': 'LIKE',
    'ilike': 'ILIKE',
    'child_of': '= ANY',  // Requiere lógica especial para jerarquías
    'parent_of': '= ANY'
  };
  return mapping[op];
}

6. Field Permissions

6.1 Modelo de Datos

-- Campos del modelo con metadatos de seguridad
CREATE TABLE core_auth.model_fields (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    model_id UUID NOT NULL REFERENCES core_auth.models(id) ON DELETE CASCADE,

    name VARCHAR(128) NOT NULL,                 -- Nombre técnico del campo
    field_type VARCHAR(64) NOT NULL,            -- Tipo: char, int, many2one, etc.
    description VARCHAR(255),                   -- Etiqueta legible

    -- Seguridad
    is_readonly BOOLEAN NOT NULL DEFAULT FALSE,
    is_required BOOLEAN NOT NULL DEFAULT FALSE,

    tenant_id UUID NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),

    CONSTRAINT uq_model_field UNIQUE (model_id, name, tenant_id)
);

-- Permisos de campo por grupo
CREATE TABLE core_auth.field_permissions (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    field_id UUID NOT NULL REFERENCES core_auth.model_fields(id) ON DELETE CASCADE,
    group_id UUID NOT NULL REFERENCES core_auth.groups(id) ON DELETE CASCADE,

    -- Permisos
    can_read BOOLEAN NOT NULL DEFAULT TRUE,
    can_write BOOLEAN NOT NULL DEFAULT FALSE,

    tenant_id UUID NOT NULL,

    CONSTRAINT uq_field_permission UNIQUE (field_id, group_id, tenant_id)
);

CREATE INDEX idx_field_permissions_field ON core_auth.field_permissions (field_id);
CREATE INDEX idx_field_permissions_group ON core_auth.field_permissions (group_id);

COMMENT ON TABLE core_auth.field_permissions IS 'Permisos de lectura/escritura por campo y grupo';

6.2 Servicio de Field Permissions

interface FieldAccessMap {
  [fieldName: string]: {
    canRead: boolean;
    canWrite: boolean;
  };
}

class FieldPermissionService {

  /**
   * Obtiene mapa de permisos de campos para un usuario en un modelo
   */
  async getFieldPermissions(
    modelName: string,
    userId: UUID
  ): Promise<FieldAccessMap> {

    const groupIds = await this.getUserGroupIds(userId);

    // Campos con permisos explícitos para los grupos del usuario
    const permissions = await db.query(`
      SELECT
        mf.name AS field_name,
        BOOL_OR(fp.can_read) AS can_read,
        BOOL_OR(fp.can_write) AS can_write
      FROM core_auth.model_fields mf
      JOIN core_auth.models m ON mf.model_id = m.id
      LEFT JOIN core_auth.field_permissions fp ON mf.id = fp.field_id
        AND fp.group_id = ANY($2)
      WHERE m.name = $1
      GROUP BY mf.name
    `, [modelName, groupIds]);

    const accessMap: FieldAccessMap = {};
    for (const p of permissions) {
      accessMap[p.field_name] = {
        // Si no hay permiso explícito, por defecto se permite
        canRead: p.can_read ?? true,
        canWrite: p.can_write ?? true
      };
    }

    return accessMap;
  }

  /**
   * Filtra campos de un registro según permisos
   */
  async filterRecordFields<T extends Record<string, any>>(
    record: T,
    modelName: string,
    userId: UUID
  ): Promise<Partial<T>> {

    const fieldAccess = await this.getFieldPermissions(modelName, userId);
    const filtered: Partial<T> = {};

    for (const [field, value] of Object.entries(record)) {
      const access = fieldAccess[field];

      // Si no hay regla explícita, permitir
      if (!access || access.canRead) {
        filtered[field as keyof T] = value;
      }
      // Si no puede leer, el campo no aparece en el resultado
    }

    return filtered;
  }

  /**
   * Valida campos antes de escribir
   */
  async validateWriteFields(
    fields: string[],
    modelName: string,
    userId: UUID
  ): Promise<{ valid: boolean; deniedFields: string[] }> {

    const fieldAccess = await this.getFieldPermissions(modelName, userId);
    const deniedFields: string[] = [];

    for (const field of fields) {
      const access = fieldAccess[field];
      if (access && !access.canWrite) {
        deniedFields.push(field);
      }
    }

    return {
      valid: deniedFields.length === 0,
      deniedFields
    };
  }
}

7. API REST

7.1 Endpoints de API Keys

POST   /api/v1/auth/api-keys              # Generar nuevo API key
GET    /api/v1/auth/api-keys              # Listar mis API keys
DELETE /api/v1/auth/api-keys/:id          # Revocar API key

POST /api/v1/auth/api-keys

Request:

{
  "name": "Integración con sistema externo",
  "scope": "rpc",
  "expirationDays": 90,
  "allowedIps": ["192.168.1.0/24", "10.0.0.5"]
}

Response:

{
  "id": "uuid",
  "name": "Integración con sistema externo",
  "apiKey": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0",
  "scope": "rpc",
  "expiresAt": "2026-03-08T00:00:00Z",
  "allowedIps": ["192.168.1.0/24", "10.0.0.5"],
  "warning": "Guarde este API key ahora. No podrá verlo de nuevo."
}

7.2 Uso del API Key

# Header Authorization
curl -H "Authorization: Bearer a1b2c3d4..." https://api.erp.com/v1/sales/orders

# Alternativamente como X-API-Key
curl -H "X-API-Key: a1b2c3d4..." https://api.erp.com/v1/sales/orders

7.3 Endpoints de Administración de Permisos

# ACL
GET    /api/v1/admin/models                     # Listar modelos
GET    /api/v1/admin/models/:id/access          # Ver ACL de modelo
PUT    /api/v1/admin/models/:id/access          # Actualizar ACL

# Record Rules
GET    /api/v1/admin/record-rules               # Listar reglas
POST   /api/v1/admin/record-rules               # Crear regla
PUT    /api/v1/admin/record-rules/:id           # Actualizar regla
DELETE /api/v1/admin/record-rules/:id           # Eliminar regla

# Field Permissions
GET    /api/v1/admin/models/:id/fields          # Campos con permisos
PUT    /api/v1/admin/fields/:id/permissions     # Actualizar permisos campo

8. Middleware de Seguridad

8.1 Middleware de Autenticación

async function authMiddleware(req: Request, res: Response, next: NextFunction) {
  try {
    // 1. Intentar JWT primero
    const jwtToken = req.headers.authorization?.replace('Bearer ', '');
    if (jwtToken && !jwtToken.match(/^[a-f0-9]{40}$/)) {
      const payload = verifyJwt(jwtToken);
      req.user = await getUserById(payload.userId);
      req.authMethod = 'jwt';
      return next();
    }

    // 2. Intentar API Key
    const apiKey = jwtToken || req.headers['x-api-key'] as string;
    if (apiKey && apiKey.match(/^[a-f0-9]{40}$/)) {
      const result = await apiKeyService.validateApiKey(
        apiKey,
        'rpc',  // scope
        req.ip
      );

      if (result.valid) {
        req.user = await getUserById(result.userId!);
        req.authMethod = 'apikey';
        req.apiKeyScope = result.scope;
        return next();
      }
    }

    throw new UnauthorizedError('Credenciales inválidas o expiradas');

  } catch (error) {
    next(error);
  }
}

8.2 Middleware de Autorización

function requireAccess(modelName: string, mode: 'read' | 'create' | 'write' | 'delete') {
  return async (req: Request, res: Response, next: NextFunction) => {
    try {
      // 1. Verificar ACL
      await modelAccessService.checkAccess(req.user.id, modelName, mode, true);

      // 2. Para lecturas, preparar dominio de record rules
      if (mode === 'read') {
        req.securityDomain = await recordRuleService.computeDomain(
          modelName, mode, req.user.id
        );
      }

      // 3. Para escrituras, validar campos permitidos
      if (mode === 'create' || mode === 'write') {
        const fieldsToWrite = Object.keys(req.body);
        const validation = await fieldPermissionService.validateWriteFields(
          fieldsToWrite, modelName, req.user.id
        );

        if (!validation.valid) {
          throw new AccessDeniedError({
            message: 'No tiene permiso para escribir algunos campos',
            deniedFields: validation.deniedFields
          });
        }
      }

      next();
    } catch (error) {
      next(error);
    }
  };
}

// Uso en rutas
router.get('/sales/orders',
  authMiddleware,
  requireAccess('sale.order', 'read'),
  listOrders
);

router.post('/sales/orders',
  authMiddleware,
  requireAccess('sale.order', 'create'),
  createOrder
);

9. Ejemplos de Configuración

9.1 Configuración Típica de Módulo de Ventas

// Grupos
const groups = [
  { code: 'sales_readonly', name: 'Ventas - Solo Lectura' },
  { code: 'sales_user', name: 'Vendedor', impliedGroups: ['sales_readonly'] },
  { code: 'sales_manager', name: 'Gerente de Ventas', impliedGroups: ['sales_user'] },
  { code: 'sales_admin', name: 'Admin de Ventas', impliedGroups: ['sales_manager'] }
];

// ACL
const acl = [
  // sale.order
  { model: 'sale.order', group: 'sales_readonly', read: true },
  { model: 'sale.order', group: 'sales_user', read: true, create: true, write: true },
  { model: 'sale.order', group: 'sales_manager', read: true, create: true, write: true, delete: true },

  // sale.order.line
  { model: 'sale.order.line', group: 'sales_readonly', read: true },
  { model: 'sale.order.line', group: 'sales_user', read: true, create: true, write: true },
  { model: 'sale.order.line', group: 'sales_manager', read: true, create: true, write: true, delete: true }
];

// Record Rules
const recordRules = [
  // Multi-empresa global
  {
    name: 'sale.order: multi-company',
    model: 'sale.order',
    domain: [['company_id', 'in', 'company_ids']],
    isGlobal: true
  },
  // Vendedor solo ve sus órdenes
  {
    name: 'sale.order: salesperson own',
    model: 'sale.order',
    domain: [['user_id', '=', 'user.id']],
    groups: ['sales_user']
  },
  // Manager ve todo
  {
    name: 'sale.order: manager all',
    model: 'sale.order',
    domain: [],  // Sin restricción adicional
    groups: ['sales_manager']
  }
];

// Field Permissions
const fieldPermissions = [
  // Descuento solo editable por manager
  { model: 'sale.order.line', field: 'discount', group: 'sales_user', canRead: true, canWrite: false },
  { model: 'sale.order.line', field: 'discount', group: 'sales_manager', canRead: true, canWrite: true },

  // Margen solo visible para manager
  { model: 'sale.order', field: 'margin', group: 'sales_user', canRead: false },
  { model: 'sale.order', field: 'margin', group: 'sales_manager', canRead: true }
];

10. Auditoría y Logging

10.1 Eventos de Seguridad a Registrar

enum SecurityEventType {
  API_KEY_CREATED = 'api_key.created',
  API_KEY_USED = 'api_key.used',
  API_KEY_REVOKED = 'api_key.revoked',
  API_KEY_EXPIRED = 'api_key.expired',

  ACCESS_DENIED_ACL = 'access.denied.acl',
  ACCESS_DENIED_RULE = 'access.denied.rule',
  ACCESS_DENIED_FIELD = 'access.denied.field',

  PERMISSION_CHANGED = 'permission.changed',
  RULE_CHANGED = 'rule.changed'
}

interface SecurityEvent {
  type: SecurityEventType;
  timestamp: Date;
  userId: UUID;
  ip: string;
  details: Record<string, any>;
}

// Logging de eventos de seguridad
async function logSecurityEvent(event: SecurityEvent): Promise<void> {
  await db.query(`
    INSERT INTO core_audit.security_events (
      event_type, timestamp, user_id, ip_address, details
    ) VALUES ($1, $2, $3, $4, $5)
  `, [event.type, event.timestamp, event.userId, event.ip, event.details]);

  // También enviar a sistema de monitoreo
  logger.security(event);
}

11. Migración desde Sistema Actual

11.1 Pasos de Migración

  1. Crear tablas de seguridad
  2. Migrar roles existentes a grupos
  3. Generar ACL a partir de permisos actuales
  4. Crear record rules para multi-empresa
  5. Configurar field permissions según necesidad
  6. Migrar credenciales de integración a API Keys

11.2 Script de Migración de Roles

-- Migrar roles existentes a grupos
INSERT INTO core_auth.groups (code, name, description, tenant_id)
SELECT
    LOWER(REPLACE(name, ' ', '_')),
    name,
    description,
    tenant_id
FROM core_auth.roles_legacy;

-- Migrar permisos de rol a ACL
INSERT INTO core_auth.model_access (name, model_id, group_id, perm_read, perm_create, perm_write, perm_delete, tenant_id)
SELECT
    rp.name,
    m.id,
    g.id,
    rp.can_read,
    rp.can_create,
    rp.can_update,
    rp.can_delete,
    rp.tenant_id
FROM core_auth.role_permissions_legacy rp
JOIN core_auth.models m ON rp.resource_name = m.name
JOIN core_auth.groups g ON g.code = LOWER(REPLACE(rp.role_name, ' ', '_'));

12. Testing

12.1 Casos de Prueba Críticos

  1. API Keys

    • Generación con duración válida/inválida
    • Autenticación con key válido/expirado/revocado
    • Restricción por IP
    • Scope validation
  2. ACL

    • Usuario sin permiso → AccessDenied
    • Usuario con grupo correcto → Acceso permitido
    • Herencia de grupos funciona
  3. Record Rules

    • Regla global aplica a todos
    • Regla de grupo solo aplica a miembros
    • Combinación AND/OR correcta
    • Multi-empresa funciona
  4. Field Permissions

    • Campo restringido no aparece en respuesta
    • Escritura de campo restringido falla

13. Referencias

  • Odoo Source: odoo/addons/base/models/res_users.py (APIKeys)
  • Odoo Source: odoo/addons/base/models/ir_model.py (IrModelAccess)
  • Odoo Source: odoo/addons/base/models/ir_rule.py (RecordRules)
  • OWASP: Access Control Cheat Sheet
  • PostgreSQL: Row-Level Security (RLS)

Historial de Cambios

Versión Fecha Autor Cambios
1.0 2025-12-08 Requirements-Analyst Versión inicial