Como evitan que una empresa vea datos de otra
company_id obligatorio en cada tabla operativa, RLS PostgreSQL filtrando por faro.current_company_id(), session context inyectado por request, tests negativos con dos tenants.
FARO-GOV-001 convierte FARO Connect en una plataforma gobernable. 12 roles canonicos, 28 permisos por modulo:resource:action, RBAC nativo, RLS de respaldo, auditoria central unica, signed URLs, API keys hasheadas, retencion declarada y tests obligatorios. Lo que un CTO serio pide antes de firmar piloto.
FARO Connect dirige empresas. Lee facturacion, cobranza, stock, evidencias y reportes ejecutivos. La pregunta no es si la seguridad importa; es si esta hecha. FARO-GOV-001 responde la grilla completa: roles, permisos, aislamiento, auditoria, archivos, IA, reportes, API keys, retencion y tests.
Toda evaluacion tecnica de plataforma multiempresa empieza por una lista corta de preguntas duras. La regla de oro: si una empresa puede leer una sola fila de otra empresa, el sistema queda rechazado. Si una accion sensible no queda auditada, queda rechazado. Si un archivo privado se descarga sin permiso, queda rechazado.
company_id obligatorio en cada tabla operativa, RLS PostgreSQL filtrando por faro.current_company_id(), session context inyectado por request, tests negativos con dos tenants.
Permiso actions:action:close requerido a nivel backend (no UI). Verificado via requirePermission() y auditado en audit.audit_log con before/after.
Permiso score:model:manage con risk_level critical. Solo Director (y limitado al Gerente General). Cambios versionados, recalculo trazado.
Permiso evidence:evidence:approve con risk_level high. Validado contra rol del usuario y categoria de evidencia. Rechazos exigen motivo.
Tabla unica audit.audit_log bajo schema audit, indexada por company/user/entity/risk. Insertada via funcion audit.log_event, no via INSERT directo.
AI Gateway con auditoria propia (FARO-AI-001). Permiso ai:explanation:generate obligatorio. Payload minimo, redaccion opcional, presupuesto diario por empresa, prompts versionados.
Storage privado por defecto. Signed URLs con TTL de 5-15 minutos. Descarga validada con files:file:download y registrada en audit con risk_level high.
Tesis ejecutiva. FARO no puede funcionar como una planilla compartida con contrasena. En un sistema de direccion, el acceso no es un detalle tecnico: es parte del producto. Quien entra, que empresa ve, que modulo opera, que accion ejecuta, que dato descarga, que evidencia aprueba, que reporte envia, que prompt IA usa, que cambio queda auditado. Cada respuesta exige una fila o tabla concreta.
Tesis de borde. Un sistema que dirige empresas no puede tener seguridad de maqueta. Si FARO ve datos de varias empresas, decide prioridades, muestra Score, evidencia, acciones y reportes, entonces debe estar blindado desde el MVP. No despues del primer cliente. No despues de la primera auditoria. Antes.
Este documento se cruza con seguridad-rls-mvp.html (capa tecnica RLS por tabla, FORCE RLS, vistas seguras) y con matriz-raci-105.html (responsabilidad por proceso). La diferencia: RLS hace cumplir el aislamiento a nivel base de datos; RACI ordena la conversacion humana; esta capa define gobierno organizacional, permisos por accion, auditoria legal y politicas transversales (IA, archivos, API, retencion).
El MVP cubre: 12 roles canonicos planos (RBAC nativo, sin extender por cliente), 28 permisos con formato module:resource:action, RLS de respaldo en todas las tablas operativas, un unico schema audit con tabla central audit.audit_log, signed URLs para evidencias, API keys hasheadas con scopes, security events para fallos de seguridad, retencion declarada por categoria, encriptacion en transito y reposo, tests obligatorios de aislamiento + permisos + auditoria.
Lo que no incluye el MVP: ABAC completo, SSO/SAML/SCIM corporativo, firma digital legal, certificacion ISO/SOC, DLP avanzado, SIEM integrado, encriptacion campo por campo, gestion de consentimientos avanzada. Estos son posteriores y se documentan en el roadmap (seccion 14).
Antes de tocar una tabla, leer estos cinco principios. Romperlos no se nota en el commit; se nota cuando un CTO pregunta por que Empresa Demo Cuyo S.A. pudo ver una accion de otra empresa o por que se cerro una accion critica sin trazabilidad.
company_id obligatorio en cada tabla operativa. Si una tabla no lo tiene, hay que justificarlo. En MVP casi nunca hay justificacion.
Actor identificado en cada request. actor_type IN ('user','system','integration','ai'). No hay operaciones huerfanas.
15 acciones sensibles definidas. Registradas en audit.audit_log via audit.log_event. before_data + after_data + diff cuando aplique.
requirePermission() obligatorio en cada endpoint sensible. La UI no es seguridad: ayuda pero no decide.
Cierres de accion, aprobaciones de evidencia, envio de reportes, recalculo de Score y cambios de modelo registran before/after + actor + timestamp + IP.
Alcance MVP (incluye). Modelo multiempresa, company_id obligatorio, RBAC basico, 12 roles canonicos, 28 permisos por modulo/accion, RLS en tablas criticas, session context PostgreSQL, auditoria tecnica + funcional, seguridad de archivos, seguridad IA, seguridad reportes, logs de acciones sensibles, retencion basica, tests de aislamiento, matriz de permisos. No incluye: ABAC avanzado, SSO/SCIM corporativo, firma digital legal, ISO/SOC, DLP, SIEM, encriptacion field-level, consentimientos avanzados, auditoria forense completa.
FARO aplica seguridad en siete capas. Cada capa tiene su responsabilidad y ninguna reemplaza a la siguiente. La UI no es seguridad; ayuda. La seguridad vive en backend y base de datos. Si la UI esconde un boton pero el endpoint no exige permiso, la capa 7 esta rota.
session.companyId obligatorio.faro.user_roles con scope (system / company / area / branch). Un usuario puede tener varios roles.module:resource:action. requirePermission() backend chequea via faro.has_permission().company_id, PostgreSQL filtra igual via policy company_id = faro.current_company_id().audit.audit_log con actor + accion + entidad + before/after + risk + IP + user_agent + request_id.Regla critica de ingenieria. Si un endpoint puede devolver datos sin que el backend haya seteado app.company_id, ese endpoint esta roto. RLS deberia devolver cero filas; si devuelve filas, una policy esta mal escrita o el rol que esta corriendo tiene bypass. Ambos casos son bloqueantes de piloto.
FARO MVP define 12 roles canonicos planos. La decision de diseno es explicita: el RBAC es nativo de FARO Connect y no se extiende por cliente. Cada empresa asigna usuarios a estos 12 roles via faro.user_roles. No hay creacion de roles custom por empresa en MVP. Eso entra recien en enterprise.
Administracion interna FARO. Soporte L3, debugging, recuperacion de incidentes. Acceso global a todas las empresas con auditoria adicional.
Ejemplo: Tomas Pombo y soporte tecnico FARO. Bypass explicito en policies via has_role('faro_super_admin'). Toda accion queda auditada con risk_level critical.
Configura empresa y usuarios. Gestiona admin:users:manage, admin:roles:manage, settings:company:update, integrations:api:write. No es ejecutivo: no aprueba acciones de negocio.
Ejemplo: CTO de Empresa Demo Cuyo S.A. onboarda directivos, configura fuentes Tango y crea API keys de ingesta. No cierra acciones operativas.
Ve todo en su empresa. Lectura completa de tensiones, acciones, evidencia, Score y reportes. Aprueba cambios criticos (modelo Score, configuracion empresa). Recibe alertas criticas.
Ejemplo: director general de Empresa Demo Cuyo S.A. abre la bandeja cada lunes, revisa Score consolidado y aprueba cierre de TNS-002 con evidencia.
Opera direccion completa. Gestiona tensiones, asigna responsables, escala acciones, genera y envia reportes. Recalcula Score (no cambia modelo).
Ejemplo: gerente general cierra TNS-002 (descuento fuera de politica) tras revisar evidencia adjunta y notifica al area comercial.
Opera su area asignada (comercial, finanzas, stock, compras, RRHH). Gestiona tensiones y acciones de su area, aprueba evidencia, genera reportes parciales.
Ejemplo: gerente comercial cierra ACT-COM-001 (revision descuentos vendedor) tras validar la evidencia subida por el responsable operativo.
Ejecuta acciones asignadas. Carga evidencia, actualiza status, escala si bloquea. Acceso limitado a sus propias acciones y tensiones asignadas.
Ejemplo: ejecutivo cuenta clave revisa cliente moroso, actualiza ACT-COB-005, sube comprobante de gestion y solicita aprobacion.
Aprueba evidencia y cierres dentro del workflow de su rol. Rechaza con motivo obligatorio. No crea acciones nuevas.
Ejemplo: supervisor cobranza valida comprobante de pago parcial cargado por el ejecutivo y aprueba el cierre de ACT-COB-005.
Gestiona fuentes y calidad de datos de su area. Configura conectores, revisa calidad, aprueba reglas de transformacion. Lectura amplia, escritura via data:sources:manage.
Ejemplo: data owner finanzas configura conector AFIP, revisa registros RAW invalidos y aprueba el contrato de datos de cobranza.
Ve datos y reportes, no decide. Acceso lectura amplia, puede generar explicaciones IA y reportes propios. No aprueba evidencia, no cierra acciones.
Ejemplo: analista de planeamiento consulta evolucion FARO Score, exporta tensiones y solicita explicacion IA sobre un KPI.
Consulta limitada. Acceso a reportes, dashboard ejecutivo parcial y Score. No exporta datos crudos, no usa IA, no aprueba ni cierra.
Ejemplo: socio inversor consulta evolucion FARO Score y reporte mensual sin acceso a operacion ni evidencia detallada.
Usuario tecnico para ingesta API y workers ETL. Sin UI, sin sesion humana. Permisos amplios para escribir RAW/staging, acotados via API keys con scopes.
Ejemplo: worker que sube CSV semanal de ventas desde Tango. Setea contexto con actor_type = 'integration' y API key especifica.
Lee auditoria y trazabilidad. Acceso a audit:log:read, ai:audit:read, reportes y security events de su empresa. Sus consultas se registran adicionalmente.
Ejemplo: auditor contable externo revisa trazabilidad de cierres de tensiones financieras del trimestre y verifica que cada cierre tiene evidencia + actor + timestamp.
Por que 12 y por que planos. Doce roles cubren los ejes que importan: gobernanza interna FARO, administracion de empresa, conduccion ejecutiva, gestion de area, ejecucion operativa, aprobacion, datos, analisis, lectura, integracion tecnica y auditoria. Roles planos significa que la jerarquia se modela via permisos, no via herencia. Mas roles + herencia = laberinto. Doce roles + matriz explicita = legible.
Diferencia con roles tecnicos DB. PostgreSQL solo conoce 4 roles (faro_app, faro_migration, faro_readonly, faro_ingestion; documentados en seguridad-rls-mvp.html). Los 12 roles funcionales viven en faro.roles y se evaluan via faro.has_permission(). Esta separacion permite mover negocio sin tocar GRANTs PostgreSQL.
Los permisos siguen un formato unico: module:resource:action. Esto los hace legibles, indexables y filtrables. Cada permiso tiene un risk_level declarado (low, medium, high, critical) que define cuanto se audita y a quien se notifica cuando se ejecuta.
| Permiso | Modulo | Recurso | Accion | Descripcion | Riesgo |
|---|---|---|---|---|---|
dashboard:executive:read | dashboard | executive | read | Ver dashboard ejecutivo de la empresa. | medium |
tensions:tension:read | tensions | tension | read | Leer tensiones de la empresa. | medium |
tensions:tension:manage | tensions | tension | manage | Cambiar estado o responsable de tensiones. | high |
actions:action:read | actions | action | read | Leer acciones operativas. | medium |
actions:action:create | actions | action | create | Crear acciones manuales nuevas. | high |
actions:action:update | actions | action | update | Actualizar acciones existentes. | medium |
actions:action:close | actions | action | close | Cerrar acciones (transicion terminal). | high |
actions:action:escalate | actions | action | escalate | Escalar accion a nivel superior. | high |
evidence:evidence:read | evidence | evidence | read | Ver evidencias cargadas. | medium |
evidence:evidence:upload | evidence | evidence | upload | Cargar archivos de evidencia. | medium |
evidence:evidence:approve | evidence | evidence | approve | Aprobar evidencia cargada. | high |
evidence:evidence:reject | evidence | evidence | reject | Rechazar evidencia (motivo obligatorio). | high |
score:snapshot:read | score | snapshot | read | Ver FARO Score y componentes. | medium |
score:snapshot:recalculate | score | snapshot | recalculate | Forzar recalculo manual de Score. | high |
score:model:manage | score | model | manage | Cambiar modelo o pesos del Score. | critical |
reports:weekly:read | reports | weekly | read | Ver reporte semanal generado. | medium |
reports:weekly:generate | reports | weekly | generate | Generar reporte semanal nuevo. | high |
reports:weekly:send | reports | weekly | send | Enviar reporte semanal por email. | high |
ai:explanation:generate | ai | explanation | generate | Usar IA controlada para generar explicaciones. | medium |
ai:audit:read | ai | audit | read | Ver requests y outputs de IA. | high |
notifications:notification:read | notifications | notification | read | Ver notificaciones recibidas. | low |
settings:company:update | settings | company | update | Modificar configuracion de empresa. | high |
admin:users:manage | admin | users | manage | Alta, baja y modificacion de usuarios. | critical |
admin:roles:manage | admin | roles | manage | Modificar roles y permisos asignados. | critical |
audit:log:read | audit | log | read | Leer registro de auditoria. | high |
files:file:download | files | file | download | Descargar archivos privados (signed URL). | high |
integrations:api:write | integrations | api | write | Ingesta o escritura por API. | critical |
data:sources:manage | data | sources | manage | Configurar fuentes de datos. | high |
Lectura horizontal. Los 28 permisos cubren los 14 modulos del MVP. Cada permiso tiene su risk_level que define: cuanto se audita (critical y high siempre, medium para acciones sensibles, low solo si hay anomalia), si genera notificacion al Director, y si dispara security event en caso de denegacion. Permisos critical son los que un CTO revisa primero: gestion de usuarios, gestion de roles, cambio de modelo Score e ingesta API.
La matriz traduce los principios a una grilla operativa. Si = permiso pleno; Area = filtrado por area_id del usuario; Asig = solo registros asignados al usuario; Parcial = lectura parcial o segun caso; Lim = lectura limitada; No = denegado; Segun rol/caso = depende del rol especifico. La matriz se mantiene como fuente unica: si un permiso cambia, se actualiza aqui y se regenera el seed SQL.
Cruz con seguridad-rls-mvp.html. Esta matriz describe permisos por rol funcional. seguridad-rls-mvp.html describe permisos por tabla SQL. La matriz RACI 105 describe responsabilidad por proceso de negocio. Las tres son complementarias: RBAC define que puede hacer cada rol, RLS hace cumplir el aislamiento por empresa, RACI ordena la conversacion humana.
| Permiso | Super Admin |
Company Admin |
Director | GG | Gte Area |
Resp. | Aprob. | Data Owner |
Analista | Viewer | Integ. | Auditor |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| dashboard:executive:read | Si | Si | Si | Si | Parcial | No | No | Parcial | Parcial | Parcial | No | Si |
| tensions:tension:read | Si | Si | Si | Si | Area | Asig | Asig | Area | Si | Lim | No | Si |
| tensions:tension:manage | Si | No | Si | Si | Area | No | No | No | No | No | No | No |
| actions:action:read | Si | Si | Si | Si | Area | Asig | Revisar | Area | Si | Lim | No | Si |
| actions:action:create | Si | No | Si | Si | Area | No | No | No | No | No | No | No |
| actions:action:update | Si | No | Si | Si | Area | Asig | No | No | No | No | No | No |
| actions:action:close | Si | No | Si | Si | Area | No | Segun rol | No | No | No | No | No |
| actions:action:escalate | Si | No | Si | Si | Area | Asig | Si | No | No | No | No | No |
| evidence:evidence:read | Si | Si | Si | Si | Area | Asig | Asig | Area | Si | No | No | Si |
| evidence:evidence:upload | Si | No | Si | Si | Si | Asig | Si | Si | No | No | Si | No |
| evidence:evidence:approve | Si | No | Si | Si | Area | No | Si | Segun caso | No | No | No | No |
| evidence:evidence:reject | Si | No | Si | Si | Area | No | Si | Segun caso | No | No | No | No |
| score:snapshot:read | Si | Si | Si | Si | Area | No | No | Area | Si | Lim | No | Si |
| score:snapshot:recalculate | Si | No | Si | Si | No | No | No | No | No | No | No | No |
| score:model:manage | Si | No | Si | Limit | No | No | No | No | No | No | No | No |
| reports:weekly:read | Si | Si | Si | Si | Area | No | No | Area | Si | Si | No | Si |
| reports:weekly:generate | Si | No | Si | Si | Area | No | No | No | No | No | No | No |
| reports:weekly:send | Si | No | Si | Si | No | No | No | No | No | No | No | No |
| ai:explanation:generate | Si | No | Si | Si | Area | Asig | Asig | Area | Si | No | No | No |
| ai:audit:read | Si | Si | Si | Si | No | No | No | No | No | No | No | Si |
| notifications:notification:read | Si | Si | Si | Si | Si | Si | Si | Si | Si | Si | No | Si |
| settings:company:update | Si | Si | Si | No | No | No | No | No | No | No | No | No |
| admin:users:manage | Si | Si | No | No | No | No | No | No | No | No | No | No |
| admin:roles:manage | Si | Si | No | No | No | No | No | No | No | No | No | No |
| audit:log:read | Si | Si | Si | Parcial | Area | No | No | No | No | No | No | Si |
| files:file:download | Si | Si | Si | Si | Area | Asig | Asig | Area | Si | No | No | Si |
| integrations:api:write | Si | Si | No | No | No | No | No | No | No | No | Si | No |
| data:sources:manage | Si | Si | No | No | No | No | No | Si | No | No | No | No |
Notas de lectura. Roles abreviados: GG = Gerente General, Gte Area = Gerente de Area, Resp. = Responsable Operativo, Aprob. = Aprobador, Integ. = integration_service. Area = filtrado por area_id del usuario en faro.user_roles. Asig = solo registros donde el usuario es responsible_user_id, approver_user_id o submitted_by. Parcial = lectura limitada, sin acciones sensibles. Limit = Gerente General puede aprobar cambios menores de Score pero no manage del modelo. Revisar = el aprobador ve acciones a su revision. Segun caso = depende del rol especifico asignado en workflow.
Antes de ejecutar cualquier query operativa, el backend debe setear tres variables de sesion PostgreSQL: app.company_id, app.user_id y app.role_codes. Estas variables son leidas por las funciones helper faro.current_company_id(), faro.current_user_id() y faro.current_role_codes(), que a su vez son consumidas por toda policy RLS y por la funcion faro.has_permission().
Regla absoluta. Sin session context seteado, no hay query operativa. Si una conexion pooled se reutiliza sin resetear contexto, se arrastra el contexto del usuario anterior. Eso no es bug menor: es incendio. Cada request debe: abrir transaccion, setear las tres variables, ejecutar queries, commit/rollback, liberar conexion.
-- Set session context (run within transaction) SELECT set_config('app.company_id', $1, true); SELECT set_config('app.user_id', $2, true); SELECT set_config('app.role_codes', $3, true); -- Examples for Empresa Demo Cuyo S.A. -- $1 = '10000000-0000-0000-0000-000000000001' (company_id) -- $2 = '12000000-0000-0000-0000-000000000001' (user_id) -- $3 = 'director,general_manager' (role_codes CSV)
CREATE OR REPLACE FUNCTION faro.current_company_id() RETURNS uuid AS $$ DECLARE v_company_id text; BEGIN v_company_id := current_setting('app.company_id', true); IF v_company_id IS NULL OR v_company_id = '' THEN RAISE EXCEPTION 'app.company_id is not set'; END IF; RETURN v_company_id::uuid; END; $$ LANGUAGE plpgsql STABLE;
CREATE OR REPLACE FUNCTION faro.current_user_id() RETURNS uuid AS $$ DECLARE v_user_id text; BEGIN v_user_id := current_setting('app.user_id', true); IF v_user_id IS NULL OR v_user_id = '' THEN RETURN NULL; END IF; RETURN v_user_id::uuid; END; $$ LANGUAGE plpgsql STABLE;
CREATE OR REPLACE FUNCTION faro.current_role_codes() RETURNS text[] AS $$ DECLARE v_roles text; BEGIN v_roles := current_setting('app.role_codes', true); IF v_roles IS NULL OR trim(v_roles) = '' THEN RETURN ARRAY[]::text[]; END IF; RETURN string_to_array(v_roles, ','); END; $$ LANGUAGE plpgsql STABLE;
Por que CSV y no array. PostgreSQL set_config() solo acepta text. Pasar arrays como JSON o text[] complica el parsing. CSV plano es suficiente para 12 roles canonicos (un usuario tiene 1-3 roles en promedio). El parsing a text[] se hace en la funcion helper y se memoiza con STABLE.
El RBAC vive en cuatro tablas: faro.permissions (catalogo de 28 permisos con risk_level), faro.roles (12 roles canonicos), faro.role_permissions (asignacion N:M), faro.user_roles (asignacion usuario-rol con scope opcional por area/branch). La funcion faro.has_permission() resuelve en una sola query si un usuario tiene el permiso solicitado en la empresa actual.
CREATE TABLE IF NOT EXISTS faro.permissions ( permission_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), permission_code text NOT NULL UNIQUE, module_code text NOT NULL, resource_code text NOT NULL, action_code text NOT NULL, name text NOT NULL, description text NOT NULL, risk_level text NOT NULL DEFAULT 'medium' CHECK ( risk_level IN ('low', 'medium', 'high', 'critical') ), is_system boolean NOT NULL DEFAULT true, is_active boolean NOT NULL DEFAULT true, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_permissions_module ON faro.permissions(module_code, is_active);
CREATE TABLE IF NOT EXISTS faro.roles ( role_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), company_id uuid NULL, -- company_id NULL solo para roles de sistema globales role_code text NOT NULL, name text NOT NULL, description text NOT NULL, role_scope text NOT NULL DEFAULT 'company' CHECK ( role_scope IN ('system', 'company', 'area', 'branch') ), is_system boolean NOT NULL DEFAULT false, is_active boolean NOT NULL DEFAULT true, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), UNIQUE(company_id, role_code) );
CREATE TABLE IF NOT EXISTS faro.role_permissions ( role_permission_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), company_id uuid NULL, role_id uuid NOT NULL REFERENCES faro.roles(role_id), permission_id uuid NOT NULL REFERENCES faro.permissions(permission_id), granted boolean NOT NULL DEFAULT true, created_at timestamptz NOT NULL DEFAULT now(), UNIQUE(role_id, permission_id) ); -- granted = false permite revocar explicitamente -- un permiso heredado en una empresa especifica
CREATE TABLE IF NOT EXISTS faro.user_roles ( user_role_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), company_id uuid NOT NULL, user_id uuid NOT NULL, role_id uuid NOT NULL REFERENCES faro.roles(role_id), area_id uuid NULL, branch_id uuid NULL, is_active boolean NOT NULL DEFAULT true, assigned_by uuid NULL, assigned_at timestamptz NOT NULL DEFAULT now(), expires_at timestamptz NULL, created_at timestamptz NOT NULL DEFAULT now(), UNIQUE(company_id, user_id, role_id, area_id, branch_id) ); CREATE INDEX IF NOT EXISTS idx_user_roles_company_user ON faro.user_roles(company_id, user_id, is_active); CREATE INDEX IF NOT EXISTS idx_user_roles_company_role ON faro.user_roles(company_id, role_id, is_active);
CREATE OR REPLACE FUNCTION faro.has_permission( p_user_id uuid, p_company_id uuid, p_permission_code text ) RETURNS boolean AS $$ DECLARE v_has_permission boolean; BEGIN SELECT EXISTS ( SELECT 1 FROM faro.user_roles ur JOIN faro.roles r ON r.role_id = ur.role_id AND r.is_active = true JOIN faro.role_permissions rp ON rp.role_id = r.role_id AND rp.granted = true JOIN faro.permissions p ON p.permission_id = rp.permission_id AND p.is_active = true WHERE ur.company_id = p_company_id AND ur.user_id = p_user_id AND ur.is_active = true AND p.permission_code = p_permission_code AND ( ur.expires_at IS NULL OR ur.expires_at > now() ) ) INTO v_has_permission; RETURN COALESCE(v_has_permission, false); END; $$ LANGUAGE plpgsql STABLE;
INSERT INTO faro.permissions ( permission_code, module_code, resource_code, action_code, name, description, risk_level ) VALUES ('tensions:tension:manage', 'tensions', 'tension', 'manage', 'Gestionar tensiones', 'Cambiar estado o responsable de tensiones.', 'high'), ('actions:action:close', 'actions', 'action', 'close', 'Cerrar acciones', 'Permite cerrar acciones.', 'high'), ('evidence:evidence:approve', 'evidence', 'evidence', 'approve', 'Aprobar evidencia', 'Permite aprobar evidencia.', 'high'), ('score:model:manage', 'score', 'model', 'manage', 'Gestionar modelo Score', 'Permite cambiar modelo o pesos del Score.', 'critical'), ('admin:users:manage', 'admin', 'users', 'manage', 'Gestionar usuarios', 'Permite alta, baja y modificacion de usuarios.', 'critical') ON CONFLICT (permission_code) DO UPDATE SET name = EXCLUDED.name, description = EXCLUDED.description, risk_level = EXCLUDED.risk_level, updated_at = now();
El seed real (V087) incluye los 28 permisos completos. El patron ON CONFLICT DO UPDATE permite refinar descripciones o risk_levels en re-runs sin perder asignaciones existentes en faro.role_permissions.
La auditoria vive en un schema dedicado audit con una unica tabla central audit.audit_log. Decision explicita: auditoria no fragmentada. No hay una tabla por modulo. Hay una tabla que registra todo evento sensible, indexada por company/user/entity/risk/time. La insercion se hace via la funcion audit.log_event, nunca via INSERT directo desde la app comun.
CREATE SCHEMA IF NOT EXISTS audit; CREATE TABLE IF NOT EXISTS audit.audit_log ( audit_log_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), company_id uuid NULL, user_id uuid NULL, actor_type text NOT NULL DEFAULT 'user' CHECK ( actor_type IN ('user', 'system', 'integration', 'ai') ), action text NOT NULL, entity_schema text NULL, entity_table text NULL, entity_type text NULL, entity_id text NULL, risk_level text NOT NULL DEFAULT 'medium' CHECK ( risk_level IN ('low', 'medium', 'high', 'critical') ), before_data jsonb NULL, after_data jsonb NULL, diff_data jsonb NULL, request_id text NULL, ip_address inet NULL, user_agent text NULL, source_module text NULL, source_reference text NULL, success boolean NOT NULL DEFAULT true, error_message text NULL, metadata jsonb NOT NULL DEFAULT '{}'::jsonb, created_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_audit_log_company_time ON audit.audit_log(company_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_audit_log_user_time ON audit.audit_log(user_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_audit_log_entity ON audit.audit_log(company_id, entity_type, entity_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_audit_log_action ON audit.audit_log(company_id, action, created_at DESC); CREATE INDEX IF NOT EXISTS idx_audit_log_risk ON audit.audit_log(company_id, risk_level, created_at DESC);
CREATE OR REPLACE FUNCTION audit.log_event( p_company_id uuid, p_user_id uuid, p_actor_type text, p_action text, p_entity_schema text DEFAULT NULL, p_entity_table text DEFAULT NULL, p_entity_type text DEFAULT NULL, p_entity_id text DEFAULT NULL, p_risk_level text DEFAULT 'medium', p_before_data jsonb DEFAULT NULL, p_after_data jsonb DEFAULT NULL, p_diff_data jsonb DEFAULT NULL, p_source_module text DEFAULT NULL, p_source_reference text DEFAULT NULL, p_success boolean DEFAULT true, p_error_message text DEFAULT NULL, p_metadata jsonb DEFAULT '{}'::jsonb ) RETURNS uuid AS $$ DECLARE v_audit_log_id uuid; BEGIN INSERT INTO audit.audit_log ( company_id, user_id, actor_type, action, entity_schema, entity_table, entity_type, entity_id, risk_level, before_data, after_data, diff_data, source_module, source_reference, success, error_message, metadata ) VALUES ( p_company_id, p_user_id, p_actor_type, p_action, p_entity_schema, p_entity_table, p_entity_type, p_entity_id, p_risk_level, p_before_data, p_after_data, p_diff_data, p_source_module, p_source_reference, p_success, p_error_message, COALESCE(p_metadata, '{}'::jsonb) ) RETURNING audit_log_id INTO v_audit_log_id; RETURN v_audit_log_id; END; $$ LANGUAGE plpgsql;
RLS sobre audit.audit_log. La auditoria se protege mas fuerte que cualquier tabla operativa: ALTER TABLE audit.audit_log ENABLE ROW LEVEL SECURITY + policy que permite ver solo company_id = faro.current_company_id() OR company_id IS NULL. No se permite INSERT directo desde app comun; toda escritura pasa por audit.log_event que corre como funcion con permisos elevados controlados.
15 acciones sensibles definidas como obligatorias en MVP. Cambiar rol (critica), cambiar permisos (critica), cambiar modelo Score (critica), recalcular Score (alta), cerrar accion critica (alta), aprobar evidencia critica (alta), rechazar evidencia critica (alta), enviar reporte ejecutivo (alta), descargar evidencia (alta), crear integracion API (critica), usar IA sobre reporte ejecutivo (media-alta), cambiar configuracion empresa (alta), desactivar regla critica (critica), cambiar vencimiento accion vencida (alta), reabrir tension (alta). Cada una llama a audit.log_event con el risk_level correspondiente.
Cada dominio funcional aplica reglas especificas que se cruzan con el gobierno transversal. Evidencias usan signed URLs con TTL corto. IA se cruza con AI Gateway (FARO-AI-001). Reportes son snapshots inmutables. Score exige permiso critical para cambiar modelo. Workflow valida transiciones permitidas con permiso + motivo.
Archivos privados por defecto. Storage sin acceso publico. La descarga genera signed URL con TTL 5-15 minutos y queda registrada.
files:file:download obligatoriostorage_key al clienteIA no escribe en tablas criticas. No accede libremente a DB. Recibe payload minimo. Toda invocacion queda auditada en ai_requests y replicada en audit.audit_log con actor_type='ai'.
ai:explanation:generate obligatorioai_enabledReportes ejecutivos son privados. El PDF no queda publico. El envio por email se audita con destinatarios. Un reporte enviado no se modifica silenciosamente: si cambia, se regenera y queda rastro.
reports:weekly:read|generate|sendScore se calcula con modelo versionado. Cambiar pesos requiere permiso critical. Recalcular se audita. Score historico no se borra. Snapshots pueden regenerarse pero queda rastro completo.
score:model:manage criticalscore_value prohibidoEl workflow de acciones y tensiones valida transiciones permitidas. Operaciones sensibles exigen permiso + motivo + auditoria. Cruza con workflow-escalamiento-mvp.html.
export async function createEvidenceDownloadUrl(params: { client: any; companyId: string; userId: string; evidenceId: string; }) { await requirePermission({ client: params.client, companyId: params.companyId, userId: params.userId, permissionCode: "files:file:download" }); const result = await params.client.query( ` SELECT evidence_id, storage_bucket, storage_key, title FROM faro.evidence WHERE company_id = $1 AND evidence_id = $2 LIMIT 1 `, [params.companyId, params.evidenceId] ); if (!result.rows[0]) throw new Error("EVIDENCE_NOT_FOUND"); /* Generar signed URL con TTL 5-15 minutos */ await auditEvent({ client: params.client, companyId: params.companyId, userId: params.userId, action: "evidence.file.download_url_created", entityType: "evidence", entityId: params.evidenceId, riskLevel: "high", sourceModule: "files", metadata: { storage_bucket: result.rows[0].storage_bucket } }); return { url: "signed-url-placeholder", expires_in_seconds: 600 }; }
Las API keys para integraciones se guardan hasheadas, nunca en plaintext. Tienen scopes acotados y se revocan instantaneamente. Los eventos de seguridad no funcionales (logins fallidos, permisos denegados, intentos de RLS bypass, abuso de descargas) viven en audit.security_events con severidad para alertar al Admin.
CREATE TABLE IF NOT EXISTS faro.api_keys ( api_key_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), company_id uuid NOT NULL, name text NOT NULL, key_prefix text NOT NULL, key_hash text NOT NULL, -- key_hash = bcrypt/argon2 sobre el token completo -- key_prefix = primeros 8 chars, para identificar visualmente scopes text[] NOT NULL DEFAULT ARRAY[]::text[], created_by uuid NULL, last_used_at timestamptz NULL, expires_at timestamptz NULL, is_active boolean NOT NULL DEFAULT true, created_at timestamptz NOT NULL DEFAULT now(), revoked_at timestamptz NULL, revoked_by uuid NULL, UNIQUE(company_id, key_prefix) ); CREATE INDEX IF NOT EXISTS idx_api_keys_company ON faro.api_keys(company_id, is_active);
| Scope | Uso | Rol equivalente |
|---|---|---|
ingestion:write | Subir datos desde ETL/cron | integration_service |
raw:write | Insertar registros RAW | integration_service |
reports:read | Leer reportes generados | analyst / viewer |
score:read | Leer FARO Score | analyst / viewer |
actions:read | Leer acciones operativas | analyst |
evidence:write | Subir evidencia por API | integration_service |
CREATE TABLE IF NOT EXISTS audit.security_events ( security_event_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), company_id uuid NULL, user_id uuid NULL, event_type text NOT NULL CHECK ( event_type IN ( 'login_success', 'login_failed', 'permission_denied', 'rls_violation_attempt', 'suspicious_download', 'api_key_created', 'api_key_revoked', 'ai_policy_violation', 'rate_limit_exceeded', 'password_reset', 'mfa_required', 'mfa_failed', 'session_expired' ) ), severity text NOT NULL CHECK ( severity IN ('low', 'medium', 'high', 'critical') ), description text NOT NULL, ip_address inet NULL, user_agent text NULL, metadata jsonb NOT NULL DEFAULT '{}'::jsonb, created_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_security_events_company_time ON audit.security_events(company_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_security_events_type ON audit.security_events(event_type, severity, created_at DESC);
Separacion clara. audit.audit_log registra acciones funcionales con before/after (cerrar accion, aprobar evidencia, cambiar modelo Score). audit.security_events registra eventos de seguridad sin payload de negocio (logins fallidos, permisos denegados, intentos de bypass). Las dos tablas viven en el mismo schema audit pero sirven a equipos distintos: la primera al auditor de negocio, la segunda al admin de seguridad.
El backend usa tres helpers TypeScript que toda ruta API debe usar: setDbSessionContext antes de cualquier query, requirePermission antes de ejecutar acciones sensibles, auditEvent despues de operaciones que dejan rastro. El middleware withFaroSecurity orquesta los tres en una transaccion.
export async function setDbSessionContext(params: { client: any; companyId: string; userId: string; roleCodes: string[]; }) { await params.client.query( `SELECT set_config('app.company_id', $1, true)`, [params.companyId] ); await params.client.query( `SELECT set_config('app.user_id', $1, true)`, [params.userId] ); await params.client.query( `SELECT set_config('app.role_codes', $1, true)`, [params.roleCodes.join(",")] ); }
export async function requirePermission(params: { client: any; companyId: string; userId: string; permissionCode: string; }) { const result = await params.client.query( `SELECT faro.has_permission($1, $2, $3) AS allowed`, [params.userId, params.companyId, params.permissionCode] ); if (!result.rows[0]?.allowed) { const error = new Error( `PERMISSION_DENIED: ${params.permissionCode}` ); (error as any).statusCode = 403; throw error; } }
export async function auditEvent(params: { client: any; companyId: string; userId?: string | null; actorType?: "user" | "system" | "integration" | "ai"; action: string; entityType?: string | null; entityId?: string | null; riskLevel?: "low" | "medium" | "high" | "critical"; beforeData?: unknown; afterData?: unknown; sourceModule?: string | null; sourceReference?: string | null; metadata?: Record<string, unknown>; }) { await params.client.query( ` SELECT audit.log_event( $1, $2, $3, $4, NULL, NULL, $5, $6, $7, $8::jsonb, $9::jsonb, NULL, $10, $11, true, NULL, $12::jsonb ) `, [ params.companyId, params.userId ?? null, params.actorType ?? "user", params.action, params.entityType ?? null, params.entityId ?? null, params.riskLevel ?? "medium", params.beforeData ? JSON.stringify(params.beforeData) : null, params.afterData ? JSON.stringify(params.afterData) : null, params.sourceModule ?? null, params.sourceReference ?? null, JSON.stringify(params.metadata ?? {}) ] ); }
export async function withFaroSecurity<T>(params: { request: Request; requiredPermission?: string; handler: (ctx: { client: any; session: { companyId: string; userId: string; roleCodes: string[] }; requestId: string; }) => Promise<T>; }) { const requestId = crypto.randomUUID(); const session = await getSessionContext(); if (!session?.companyId || !session?.userId) { throw new Error("UNAUTHORIZED"); } const client = await db.connect(); try { await client.query("BEGIN"); await setDbSessionContext({ client, companyId: session.companyId, userId: session.userId, roleCodes: session.roleCodes }); if (params.requiredPermission) { await requirePermission({ client, companyId: session.companyId, userId: session.userId, permissionCode: params.requiredPermission }); } const result = await params.handler({ client, session, requestId }); await client.query("COMMIT"); return result; } catch (error) { await client.query("ROLLBACK"); throw error; } finally { client.release(); } }
export async function POST( request: Request, { params }: { params: { id: string } } ) { return withFaroSecurity({ request, requiredPermission: "actions:action:close", handler: async ({ client, session }) => { const before = await client.query( `SELECT * FROM faro.actions WHERE company_id = $1 AND action_id = $2 FOR UPDATE`, [session.companyId, params.id] ); if (!before.rows[0]) throw new Error("ACTION_NOT_FOUND"); const result = await client.query( `UPDATE faro.actions SET status = 'closed', closed_at = now(), updated_at = now() WHERE company_id = $1 AND action_id = $2 RETURNING *`, [session.companyId, params.id] ); await auditEvent({ client, companyId: session.companyId, userId: session.userId, action: "action.close", entityType: "action", entityId: params.id, riskLevel: "high", beforeData: before.rows[0], afterData: result.rows[0], sourceModule: "workflow" }); return Response.json({ ok: true, action: result.rows[0] }); } }); }
Un piloto serio define que datos vive cuanto, como se cifran, que se loguea (y que no), y como se devuelven errores sin filtrar informacion. Estas politicas no son glamour; son la diferencia entre un sistema que pasa due diligence y uno que la pierde.
CREATE TABLE IF NOT EXISTS faro.data_retention_policies ( data_retention_policy_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), company_id uuid NOT NULL, data_category text NOT NULL CHECK ( data_category IN ( 'raw_data', 'staging_data', 'evidence_files', 'reports', 'audit_logs', 'ai_requests', 'notifications', 'execution_events' ) ), retention_days integer NOT NULL, archive_after_days integer NULL, delete_after_days integer NULL, legal_hold boolean NOT NULL DEFAULT false, is_active boolean NOT NULL DEFAULT true, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), UNIQUE(company_id, data_category) );
| Categoria | Retencion | Comentario |
|---|---|---|
| RAW data | 180-365 dias | Reproceso permitido en ventana corta |
| Staging | 180 dias | Limpieza periodica |
| Evidencias | 3-5 anos | Soporte legal de cierres operativos |
| Reportes | 3-5 anos | Snapshot historico ejecutivo |
| Audit logs | 5 anos | Trazabilidad compliance |
| AI requests | 180-365 dias | Auditoria + costos |
| Notificaciones | 180 dias | Volumen alto, limpieza periodica |
| Execution events | 3-5 anos | Trazabilidad operativa |
| Elemento | Control | Posterior |
|---|---|---|
| En transito | HTTPS / TLS 1.3 | mTLS para integraciones |
| Base de datos | Encriptacion managed provider | BYOK + KMS |
| Storage | Privado + encriptado at-rest | Object lock + WORM |
| API keys | Hash bcrypt/argon2, nunca plaintext | HSM / vault |
| Signed URLs | TTL 5-15 minutos | One-time URLs |
| Secrets | Variables entorno / secret manager | Rotacion automatica |
| Logs | Sin secretos, redaccion de PII | Field-level encryption |
request_id, latencia, status_code, ruta, metodo, company_id, user_id, errores controlados, duracion de queries criticas, hits de cache.
Archivos completos, API keys, tokens, passwords, payloads sensibles completos, signed URLs, datos PII sin redactar, stack traces internos en respuesta al cliente.
{
"error": "PERMISSION_DENIED",
"message": "No tenes permiso para realizar esta accion.",
"request_id": "req_a4f8c2..."
}
Nunca devolver al cliente. Stack trace completo, SQL completo, nombres internos sensibles, secrets, paths internos del filesystem, IDs de filas que el usuario no podria saber. El error que recibe el cliente es controlado, con codigo + mensaje + request_id. El detalle real va al log servidor con el mismo request_id para correlacionar.
Sin tests, no hay seguridad. Hay esperanza. El MVP exige tres familias de tests obligatorios: RLS (dos tenants, queries cruzadas devuelven cero), permisos (cada rol obtiene 200 en lo permitido y 403 en lo denegado), auditoria (cada accion sensible crea fila en audit.audit_log).
| Test | Resultado esperado |
|---|---|
Usuario empresa A no ve filas empresa B en faro.tensions | OK |
Query sin company_id context lanza excepcion | OK |
INSERT con company_id distinto al contexto falla | OK |
| Score snapshots aislados por empresa | OK |
| Reports aislados por empresa | OK |
| Evidence aislada por empresa | OK |
| AI requests aislados por empresa | OK |
| Audit logs aislados por empresa (NULL solo para sistema) | OK |
| Test | Resultado esperado |
|---|---|
| Director ve Score | 200 |
| Viewer no recalcula Score | 403 |
| Responsable Operativo no aprueba evidencia critica | 403 |
| Aprobador aprueba evidencia asignada | 200 |
| Analista no cierra accion | 403 |
| Gerente General genera reporte | 200 |
| Responsable Operativo no envia reporte | 403 |
| Company Admin gestiona usuarios | 200 |
| Director cambia modelo Score | 200 |
| Gerente General NO cambia modelo Score (solo limitado) | 403 |
| Test | Resultado esperado |
|---|---|
Cerrar accion crea audit_log con risk_level high + before/after | OK |
Aprobar evidencia crea audit_log con risk_level high | OK |
Enviar reporte crea audit_log con destinatarios en metadata | OK |
Recalcular Score crea audit_log con risk_level high | OK |
Permission denied crea security_events | OK |
Descargar evidencia crea audit_log + signed URL en metadata | OK |
Cambiar modelo Score crea audit_log risk_level critical | OK |
| Test | Resultado esperado |
|---|---|
| Crear API key guarda hash, nunca plaintext | OK |
| Key revocada no funciona (revoked_at not null) | OK |
| Scope invalido rechaza request | OK |
Uso valido actualiza last_used_at | OK |
| Key expirada (expires_at past) no funciona | OK |
import { describe, expect, it } from "vitest"; import { withTestDbContext } from "../src/helpers/dbTestContext"; describe("RLS company isolation", () => { it("does not allow company A to read company B actions", async () => { await withTestDbContext( { companyId: "10000000-0000-0000-0000-000000000001", userId: "12000000-0000-0000-0000-000000000001", roleCodes: ["general_manager"] }, async (client) => { const result = await client.query(` SELECT * FROM faro.actions WHERE company_id = '99900000-0000-0000-0000-000000000001' `); expect(result.rows.length).toBe(0); } ); }); });
describe("Permissions", () => { it("rejects viewer without score recalculation permission", async () => { await withTestDbContext( { companyId: "10000000-0000-0000-0000-000000000001", userId: "12000000-0000-0000-0000-000000000099", roleCodes: ["viewer"] }, async (client) => { await expect( requirePermission({ client, companyId: "10000000-0000-0000-0000-000000000001", userId: "12000000-0000-0000-0000-000000000099", permissionCode: "score:snapshot:recalculate" }) ).rejects.toThrow("PERMISSION_DENIED"); } ); }); });
FARO-GOV-001 queda aceptado cuando cumple los criterios funcionales y tecnicos. Se rechaza ante cualquier caso de la lista bloqueante. Los riesgos quedan mitigados con controles concretos. El monitoreo expone metricas accionables. El roadmap deja claro que queda en MVP y que llega en enterprise.
requirePermissionactions:action:closeai:explanation:generatefiles:file:download + auditaudit.log_eventapp.company_id en cada request (critica)| Riesgo | Mitigacion |
|---|---|
| Cruce de datos entre empresas | RLS + company_id + tests negativos dos tenants |
| Permisos mal aplicados | requirePermission central + tests por rol |
| Auditoria incompleta | auditEvent obligatorio + checklist de acciones sensibles |
| Archivos expuestos | Storage privado + signed URL con TTL corto |
| IA revela datos | Payload minimo + RLS + auditoria + redaccion opcional |
| API key comprometida | Hash + scopes + revocacion instantanea + alerta |
| Reporte enviado mal | Recipients auditados + confirmacion + alerta cross-empresa |
| Score manipulado | Modelo versionado + permiso critical + audit |
| Logs con datos sensibles | Redaccion + politica de logs + revision periodica |
| CTO cuestiona seguridad en pre-venta | Esta matriz + SQL + tests + demo de aislamiento |
| Metrica | Uso | Alerta |
|---|---|---|
permission_denied_count | Detectar problemas de config o ataques | Spike sostenido → Admin |
failed_login_count | Seguridad de cuentas | > 5/min mismo usuario → bloqueo |
sensitive_download_count | Descargas evidencias/reportes | > umbral diario → Director |
ai_policy_violation_count | Seguridad IA (FARO-AI-001) | Cualquiera critica → Admin |
api_key_usage_count | Integraciones | IP nueva → Admin |
rls_error_count | Seguridad DB | Cualquier > 0 → Admin (deberia ser cero) |
audit_log_volume | Auditoria saludable | Drop abrupto → posible falla |
score_recalculation_count | Gobierno Score | Cambio de modelo → Director |
Capa tecnica RLS por tabla, FORCE RLS, vistas seguras, V014-V023. Esta capa define gobierno + permisos. La de RLS hace cumplir el aislamiento.
Modelo SQL base del MVP. Las migraciones V081-V096 documentadas aqui se suman a las V001-V023 existentes.
RACI define responsabilidad por proceso humano (quien aprueba el cierre de TNS-002). Esta capa define permiso por accion tecnica.
Workflow de acciones y tensiones. Las transiciones permitidas se hacen cumplir aqui con permisos + auditoria + motivo obligatorio.
AI Gateway controlado (FARO-AI-001). El gobierno transversal de IA documentado aqui se cruza con prompts, budget y auditoria propia.
Observabilidad, jobs, errores y operacion tecnica (FARO-OPS-001). Las metricas de seguridad aqui definidas se exponen por ahi.
Estrategia de deploy y ambientes. Secrets, encriptacion at-rest y rotacion de keys quedan ahi.
Por que esto convierte FARO en plataforma seria. No alcanza con calcular bien. No alcanza con mostrar lindo. No alcanza con tener IA. Hay que saber quien vio, quien hizo, quien aprobo, quien cambio, quien descargo y bajo que permiso. Cada respuesta exige una tabla, una funcion, una policy y un test. Sin gobierno, FARO es una demo potente. Con gobierno, FARO empieza a ser una plataforma seria para empresas reales.
FARO-GOV-001 es la base que un CTO firma antes de habilitar piloto. Cruza con FARO-AI-001 (gobierno IA), FARO-OPS-001 (observabilidad), FARO-SEC (seguridad de plataforma), FARO-ENTERPRISE (multiempresa avanzado). Volver al hub para ver el resto del pack NDA o seguir con seguridad RLS.
→ Volver al hub modelos NDA