# 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 ```sql 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 ```sql -- 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 ```typescript // 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 { // 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 { // 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 { 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 { 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 { 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 ```typescript // Job programado (cron) para limpiar keys expirados @Cron('0 2 * * *') // Diariamente a las 2 AM async cleanupExpiredApiKeys(): Promise { 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 ```sql -- 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 ```typescript interface AccessCheckResult { hasAccess: boolean; deniedReason?: string; } class ModelAccessService { // Cache de permisos por usuario private accessCache = new Map>(); /** * 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 { // 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 { 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 ```csv # 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 ```sql 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 ```typescript 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 ```typescript 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 { 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 { 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 ```json // 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 ```typescript 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 = { '=': '=', '!=': '!=', '>': '>', '>=': '>=', '<': '<', '<=': '<=', '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 ```sql -- 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 ```typescript 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 { 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>( record: T, modelName: string, userId: UUID ): Promise> { const fieldAccess = await this.getFieldPermissions(modelName, userId); const filtered: Partial = {}; 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:** ```json { "name": "Integración con sistema externo", "scope": "rpc", "expirationDays": 90, "allowedIps": ["192.168.1.0/24", "10.0.0.5"] } ``` **Response:** ```json { "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 ```bash # 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 ```typescript 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 ```typescript 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 ```typescript // 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 ```typescript 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; } // Logging de eventos de seguridad async function logSecurityEvent(event: SecurityEvent): Promise { 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 ```sql -- 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 |