43 KiB
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
- Defense in Depth: Múltiples capas de seguridad
- Least Privilege: Permisos mínimos por defecto
- Explicit Deny: Sin permiso explícito = denegado
- 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
- Crear tablas de seguridad
- Migrar roles existentes a grupos
- Generar ACL a partir de permisos actuales
- Crear record rules para multi-empresa
- Configurar field permissions según necesidad
- 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
-
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
-
ACL
- Usuario sin permiso → AccessDenied
- Usuario con grupo correcto → Acceso permitido
- Herencia de grupos funciona
-
Record Rules
- Regla global aplica a todos
- Regla de grupo solo aplica a miembros
- Combinación AND/OR correcta
- Multi-empresa funciona
-
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 |