01 · Resumen ejecutivo

Lo que un CTO serio pregunta 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.

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.

Quien puede cerrar acciones criticas

Permiso actions:action:close requerido a nivel backend (no UI). Verificado via requirePermission() y auditado en audit.audit_log con before/after.

Quien puede cambiar pesos del Score

Permiso score:model:manage con risk_level critical. Solo Director (y limitado al Gerente General). Cambios versionados, recalculo trazado.

Quien puede aprobar evidencia

Permiso evidence:evidence:approve con risk_level high. Validado contra rol del usuario y categoria de evidencia. Rechazos exigen motivo.

Donde queda auditado

Tabla unica audit.audit_log bajo schema audit, indexada por company/user/entity/risk. Insertada via funcion audit.log_event, no via INSERT directo.

Como controlan la IA

AI Gateway con auditoria propia (FARO-AI-001). Permiso ai:explanation:generate obligatorio. Payload minimo, redaccion opcional, presupuesto diario por empresa, prompts versionados.

Como protegen archivos

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).

02 · Principio rector + alcance + 7 capas

Cinco reglas que sostienen toda la capa

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.

Todo dato pertenece a una empresa

company_id obligatorio en cada tabla operativa. Si una tabla no lo tiene, hay que justificarlo. En MVP casi nunca hay justificacion.

Toda operacion pertenece a un usuario o sistema

Actor identificado en cada request. actor_type IN ('user','system','integration','ai'). No hay operaciones huerfanas.

Toda accion sensible queda auditada

15 acciones sensibles definidas. Registradas en audit.audit_log via audit.log_event. before_data + after_data + diff cuando aplique.

Todo acceso debe estar autorizado

requirePermission() obligatorio en cada endpoint sensible. La UI no es seguridad: ayuda pero no decide.

Todo cierre debe ser trazable

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.

Modelo de seguridad por capas · 7 defensas en serie

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.

Capa 01
Autenticacion
Saber quien es el usuario. Email + password con hash seguro, JWT/cookie firmada, sesion con expiracion. MFA opcional preparado para enterprise.
auth provider + sesion
Capa 02
Empresa activa
Saber en que empresa opera. Un usuario puede pertenecer a varias empresas (multi-org), pero cada request opera en una sola. session.companyId obligatorio.
tenant selector + sesion
Capa 03
Rol
Saber que funcion tiene. Roles asignados via faro.user_roles con scope (system / company / area / branch). Un usuario puede tener varios roles.
user_roles + role_codes
Capa 04
Permiso
Saber que puede hacer. 28 permisos en formato module:resource:action. requirePermission() backend chequea via faro.has_permission().
permissions + role_permissions
Capa 05
RLS
Bloquear datos cruzados desde DB. Si el backend olvida filtrar por company_id, PostgreSQL filtra igual via policy company_id = faro.current_company_id().
PostgreSQL RLS policies
Capa 06
Auditoria
Registrar acciones sensibles. Tabla central audit.audit_log con actor + accion + entidad + before/after + risk + IP + user_agent + request_id.
audit.log_event obligatorio
Capa 07
UI
Mostrar solo lo permitido. Esconde botones, secciones y exportaciones segun rol. Ayuda, no decide. El backend siempre revalida.
PermissionGate + AccessDenied

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.

03 · 12 roles canonicos MVP

RBAC nativo plano, sin extender por cliente

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.

faro_super_admin System

Super Admin FARO

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.

company_admin Company

Admin Empresa

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.

director Company

Director

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.

general_manager Company

Gerente General

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.

area_manager Area

Gerente de Area

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.

responsible_owner Own

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.

approver Own

Aprobador

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.

data_owner Area

Data Owner

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.

analyst Company

Analista

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.

viewer Company

Solo Lectura

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.

integration_service Service

Integracion

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.

auditor Company

Auditor

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.

04 · 28 permisos canonicos MVP

Formato module:resource:action · grilla densa

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.

dashboard tensions actions evidence score reports ai notifications settings admin audit files integrations data
Permiso Modulo Recurso Accion Descripcion Riesgo
dashboard:executive:readdashboardexecutivereadVer dashboard ejecutivo de la empresa.medium
tensions:tension:readtensionstensionreadLeer tensiones de la empresa.medium
tensions:tension:managetensionstensionmanageCambiar estado o responsable de tensiones.high
actions:action:readactionsactionreadLeer acciones operativas.medium
actions:action:createactionsactioncreateCrear acciones manuales nuevas.high
actions:action:updateactionsactionupdateActualizar acciones existentes.medium
actions:action:closeactionsactioncloseCerrar acciones (transicion terminal).high
actions:action:escalateactionsactionescalateEscalar accion a nivel superior.high
evidence:evidence:readevidenceevidencereadVer evidencias cargadas.medium
evidence:evidence:uploadevidenceevidenceuploadCargar archivos de evidencia.medium
evidence:evidence:approveevidenceevidenceapproveAprobar evidencia cargada.high
evidence:evidence:rejectevidenceevidencerejectRechazar evidencia (motivo obligatorio).high
score:snapshot:readscoresnapshotreadVer FARO Score y componentes.medium
score:snapshot:recalculatescoresnapshotrecalculateForzar recalculo manual de Score.high
score:model:managescoremodelmanageCambiar modelo o pesos del Score.critical
reports:weekly:readreportsweeklyreadVer reporte semanal generado.medium
reports:weekly:generatereportsweeklygenerateGenerar reporte semanal nuevo.high
reports:weekly:sendreportsweeklysendEnviar reporte semanal por email.high
ai:explanation:generateaiexplanationgenerateUsar IA controlada para generar explicaciones.medium
ai:audit:readaiauditreadVer requests y outputs de IA.high
notifications:notification:readnotificationsnotificationreadVer notificaciones recibidas.low
settings:company:updatesettingscompanyupdateModificar configuracion de empresa.high
admin:users:manageadminusersmanageAlta, baja y modificacion de usuarios.critical
admin:roles:manageadminrolesmanageModificar roles y permisos asignados.critical
audit:log:readauditlogreadLeer registro de auditoria.high
files:file:downloadfilesfiledownloadDescargar archivos privados (signed URL).high
integrations:api:writeintegrationsapiwriteIngesta o escritura por API.critical
data:sources:managedatasourcesmanageConfigurar 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.

05 · Matriz permisos · 12 roles x 28 permisos

La grilla que un CTO revisa primero

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:readSiSiSiSiParcialNoNoParcialParcialParcialNoSi
tensions:tension:readSiSiSiSiAreaAsigAsigAreaSiLimNoSi
tensions:tension:manageSiNoSiSiAreaNoNoNoNoNoNoNo
actions:action:readSiSiSiSiAreaAsigRevisarAreaSiLimNoSi
actions:action:createSiNoSiSiAreaNoNoNoNoNoNoNo
actions:action:updateSiNoSiSiAreaAsigNoNoNoNoNoNo
actions:action:closeSiNoSiSiAreaNoSegun rolNoNoNoNoNo
actions:action:escalateSiNoSiSiAreaAsigSiNoNoNoNoNo
evidence:evidence:readSiSiSiSiAreaAsigAsigAreaSiNoNoSi
evidence:evidence:uploadSiNoSiSiSiAsigSiSiNoNoSiNo
evidence:evidence:approveSiNoSiSiAreaNoSiSegun casoNoNoNoNo
evidence:evidence:rejectSiNoSiSiAreaNoSiSegun casoNoNoNoNo
score:snapshot:readSiSiSiSiAreaNoNoAreaSiLimNoSi
score:snapshot:recalculateSiNoSiSiNoNoNoNoNoNoNoNo
score:model:manageSiNoSiLimitNoNoNoNoNoNoNoNo
reports:weekly:readSiSiSiSiAreaNoNoAreaSiSiNoSi
reports:weekly:generateSiNoSiSiAreaNoNoNoNoNoNoNo
reports:weekly:sendSiNoSiSiNoNoNoNoNoNoNoNo
ai:explanation:generateSiNoSiSiAreaAsigAsigAreaSiNoNoNo
ai:audit:readSiSiSiSiNoNoNoNoNoNoNoSi
notifications:notification:readSiSiSiSiSiSiSiSiSiSiNoSi
settings:company:updateSiSiSiNoNoNoNoNoNoNoNoNo
admin:users:manageSiSiNoNoNoNoNoNoNoNoNoNo
admin:roles:manageSiSiNoNoNoNoNoNoNoNoNoNo
audit:log:readSiSiSiParcialAreaNoNoNoNoNoNoSi
files:file:downloadSiSiSiSiAreaAsigAsigAreaSiNoNoSi
integrations:api:writeSiSiNoNoNoNoNoNoNoNoSiNo
data:sources:manageSiSiNoNoNoNoNoSiNoNoNoNo

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.

06 · Session context PostgreSQL

Tres variables que sostienen toda la seguridad

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.

▸ Setear contexto en cada request · SQL
-- 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)

faro.current_company_id() · obligatoria

▸ funcion · lanza excepcion si no esta seteado
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;

faro.current_user_id() · NULL si no hay sesion humana

▸ funcion · NULL aceptable (jobs sin usuario)
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;

faro.current_role_codes() · array vacio si no hay

▸ funcion · parsea CSV a text[]
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.

07 · DDL RBAC + has_permission

Cuatro tablas y una funcion que sostienen 28 permisos

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.

faro.permissions · catalogo canonico

▸ V082__create_permissions.sql
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);

faro.roles · 12 canonicos + custom futuro

▸ V083__create_roles.sql
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)
);

faro.role_permissions · N:M con granted boolean

▸ V084__create_role_permissions.sql
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

faro.user_roles · scope opcional + expiracion

▸ V085__create_user_roles.sql
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);

faro.has_permission() · una sola query

▸ V086__create_has_permission_function.sql
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;
Seed inicial de permisos (V087) · ejemplo de 5 permisos
▸ V087__seed_permissions_mvp.sql · ON CONFLICT DO UPDATE
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.

08 · Auditoria tecnica · schema audit

Una sola tabla, una sola funcion, tres categorias de evento

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.

audit.audit_log · schema dedicado

▸ V090__create_audit_schema_and_log.sql
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);

audit.log_event · funcion unica de insercion

▸ V091__create_audit_log_event_function.sql
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.

Tres categorias de eventos auditados

Critical · auditar + notificar Director

Cambios de gobierno

  • Cambiar rol de usuario
  • Cambiar permisos asignados a rol
  • Cambiar modelo del Score (pesos, formula)
  • Crear/revocar API key
  • Desactivar regla critica del motor
  • Cambiar configuracion de empresa
Important · auditar siempre

Operacion sensible

  • Cerrar accion critica
  • Aprobar / rechazar evidencia critica
  • Enviar reporte ejecutivo
  • Descargar evidencia
  • Recalcular Score manualmente
  • Cambiar vencimiento de accion vencida
  • Reabrir tension cerrada
  • Usar IA sobre reporte ejecutivo
Informational · auditar selectivo

Trazabilidad operativa

  • Login exitoso
  • Logout / sesion expirada
  • Cargar evidencia normal
  • Generar explicacion IA estandar
  • Lectura de auditoria (auditor)
  • Ver reporte semanal

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.

09 · Seguridad por dominio

Evidencias, IA, reportes, Score y workflow

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.

Evidencias

Signed URLs + auditoria

Archivos privados por defecto. Storage sin acceso publico. La descarga genera signed URL con TTL 5-15 minutos y queda registrada.

  • Permiso files:file:download obligatorio
  • No exponer storage_key al cliente
  • Validar MIME + extension + tamano
  • Nunca confiar en filename del usuario
  • Virus scan previsto en pipeline
  • Audit con risk_level high
IA · FARO-AI-001

Audit cruzado con AI Gateway

IA 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'.

  • Permiso ai:explanation:generate obligatorio
  • Prompts versionados, outputs con source_refs
  • Presupuesto diario por empresa
  • Redaccion opcional de datos sensibles
  • IA apagable por empresa via ai_enabled
  • Costos registrados por request
Reportes

Snapshot inmutable + envio auditado

Reportes 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.

  • Permisos reports:weekly:read|generate|send
  • Destinatarios guardados con audit
  • Regeneracion crea nuevo reporte (no modifica)
  • Archivar reporte se audita
  • Reporte enviado fuera dominio: alerta Director
Score

Modelo versionado · model:manage critical

Score 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.

  • Permiso score:model:manage critical
  • Solo Director (Gerente General limitado)
  • UPDATE manual de score_value prohibido
  • Recalculo via motor + auditoria
  • Cambio de modelo notifica al Director
Workflow

Transiciones validas con motivo

El workflow de acciones y tensiones valida transiciones permitidas. Operaciones sensibles exigen permiso + motivo + auditoria. Cruza con workflow-escalamiento-mvp.html.

  • Cerrar accion: permiso + evidencia + auditoria
  • Escalar accion: permiso + motivo
  • Bloquear accion: motivo obligatorio
  • Cambiar responsable: permiso + auditoria
  • Cambiar vencimiento: permiso + auditoria
  • Aprobar evidencia: permiso + rol
  • Rechazar evidencia: motivo obligatorio
  • Reabrir tension: permiso + motivo
Ejemplo · createEvidenceDownloadUrl con permiso + audit
▸ TypeScript · signed URL flow
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 };
}
10 · API keys hasheadas + security_events

Integraciones controladas + eventos no funcionales

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.

faro.api_keys · hash + scopes + revocacion

▸ V094__create_api_keys.sql
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);

Scopes MVP soportados

Scope Uso Rol equivalente
ingestion:writeSubir datos desde ETL/cronintegration_service
raw:writeInsertar registros RAWintegration_service
reports:readLeer reportes generadosanalyst / viewer
score:readLeer FARO Scoreanalyst / viewer
actions:readLeer acciones operativasanalyst
evidence:writeSubir evidencia por APIintegration_service

audit.security_events · eventos no funcionales

▸ V093__create_security_events.sql
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.

11 · Backend TypeScript · helpers de seguridad

Tres helpers que sostienen toda la app

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.

setDbSessionContext · inyectar contexto en PG

▸ src/security/setDbSessionContext.ts
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(",")]
  );
}

requirePermission · middleware con 403

▸ src/security/requirePermission.ts
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;
  }
}

auditEvent · wrapper sobre audit.log_event

▸ src/security/auditEvent.ts
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 ?? {})
    ]
  );
}

withFaroSecurity · middleware orquestador

▸ src/security/withFaroSecurity.ts
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();
  }
}
Ejemplo · endpoint protegido POST /actions/:id/close
▸ app/api/v1/actions/[id]/close/route.ts
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] });
    }
  });
}
12 · Retencion + cifrado + logs + errores

Politicas transversales que un auditor revisa

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.

Retencion declarada por categoria

▸ V095__create_data_retention_policies.sql
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)
);

Politica MVP sugerida

Categoria Retencion Comentario
RAW data180-365 diasReproceso permitido en ventana corta
Staging180 diasLimpieza periodica
Evidencias3-5 anosSoporte legal de cierres operativos
Reportes3-5 anosSnapshot historico ejecutivo
Audit logs5 anosTrazabilidad compliance
AI requests180-365 diasAuditoria + costos
Notificaciones180 diasVolumen alto, limpieza periodica
Execution events3-5 anosTrazabilidad operativa

Encriptacion · MVP

Elemento Control Posterior
En transitoHTTPS / TLS 1.3mTLS para integraciones
Base de datosEncriptacion managed providerBYOK + KMS
StoragePrivado + encriptado at-restObject lock + WORM
API keysHash bcrypt/argon2, nunca plaintextHSM / vault
Signed URLsTTL 5-15 minutosOne-time URLs
SecretsVariables entorno / secret managerRotacion automatica
LogsSin secretos, redaccion de PIIField-level encryption

Logs tecnicos · que se loguea, que NO

SI loguear

request_id, latencia, status_code, ruta, metodo, company_id, user_id, errores controlados, duracion de queries criticas, hits de cache.

NO loguear

Archivos completos, API keys, tokens, passwords, payloads sensibles completos, signed URLs, datos PII sin redactar, stack traces internos en respuesta al cliente.

Error handling seguro

▸ JSON · response shape para errores controlados
{
  "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.

13 · Tests obligatorios

RLS dos tenants, permisos por rol, auditoria legal

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).

Tests RLS · aislamiento por empresa

Test Resultado esperado
Usuario empresa A no ve filas empresa B en faro.tensionsOK
Query sin company_id context lanza excepcionOK
INSERT con company_id distinto al contexto fallaOK
Score snapshots aislados por empresaOK
Reports aislados por empresaOK
Evidence aislada por empresaOK
AI requests aislados por empresaOK
Audit logs aislados por empresa (NULL solo para sistema)OK

Tests permisos · 12 roles x acciones criticas

Test Resultado esperado
Director ve Score200
Viewer no recalcula Score403
Responsable Operativo no aprueba evidencia critica403
Aprobador aprueba evidencia asignada200
Analista no cierra accion403
Gerente General genera reporte200
Responsable Operativo no envia reporte403
Company Admin gestiona usuarios200
Director cambia modelo Score200
Gerente General NO cambia modelo Score (solo limitado)403

Tests auditoria · cada accion sensible deja rastro

Test Resultado esperado
Cerrar accion crea audit_log con risk_level high + before/afterOK
Aprobar evidencia crea audit_log con risk_level highOK
Enviar reporte crea audit_log con destinatarios en metadataOK
Recalcular Score crea audit_log con risk_level highOK
Permission denied crea security_eventsOK
Descargar evidencia crea audit_log + signed URL en metadataOK
Cambiar modelo Score crea audit_log risk_level criticalOK

Tests API keys

Test Resultado esperado
Crear API key guarda hash, nunca plaintextOK
Key revocada no funciona (revoked_at not null)OK
Scope invalido rechaza requestOK
Uso valido actualiza last_used_atOK
Key expirada (expires_at past) no funcionaOK
Ejemplo · test RLS dos tenants (vitest)
▸ security.rls.test.ts
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);
      }
    );
  });
});
Ejemplo · test permiso negado (vitest)
▸ security.permissions.test.ts
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");
      }
    );
  });
});
14 · Aceptacion, rechazo, riesgos, monitoreo y roadmap

El cierre que un Director y un CTO firman juntos

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.

Criterios de aceptacion

  • Existe modelo RBAC con 4 tablas (permissions, roles, role_permissions, user_roles)
  • Existen 12 roles canonicos seedados
  • Existen 28 permisos MVP seedados con risk_level
  • Usuario solo ve datos de su empresa (RLS activo)
  • RLS protege todas las tablas operativas criticas
  • Backend valida permisos via requirePermission
  • Cierre de accion exige permiso actions:action:close
  • Evidencia exige permiso y queda auditada
  • Reporte ejecutivo exige permiso para enviar
  • Score exige permiso critical para cambiar modelo
  • IA exige permiso ai:explanation:generate
  • Descarga de archivos exige files:file:download + audit
  • Auditoria registra acciones sensibles via audit.log_event
  • Security events registran fallos no funcionales
  • API keys tienen scopes acotados + hash
  • Retencion declarada por categoria
  • Tests RLS, permisos y auditoria pasan en CI

Criterios de rechazo (bloqueantes)

  • Una empresa puede leer datos de otra (critica)
  • Tablas criticas sin RLS activado (critica)
  • Backend no setea app.company_id en cada request (critica)
  • Acciones sensibles sin auditoria (alta)
  • Score se puede modificar manualmente via UPDATE (critica)
  • Evidencias descargables publicamente (critica)
  • API keys guardadas en texto plano (critica)
  • IA sin auditoria (alta)
  • Reportes publicos sin permiso (critica)
  • Permisos solo en UI, no en backend (critica)
  • Usuario sin permiso puede cerrar accion (critica)
  • No hay tests de aislamiento dos tenants (alta)

Riesgos y mitigaciones

Riesgo Mitigacion
Cruce de datos entre empresasRLS + company_id + tests negativos dos tenants
Permisos mal aplicadosrequirePermission central + tests por rol
Auditoria incompletaauditEvent obligatorio + checklist de acciones sensibles
Archivos expuestosStorage privado + signed URL con TTL corto
IA revela datosPayload minimo + RLS + auditoria + redaccion opcional
API key comprometidaHash + scopes + revocacion instantanea + alerta
Reporte enviado malRecipients auditados + confirmacion + alerta cross-empresa
Score manipuladoModelo versionado + permiso critical + audit
Logs con datos sensiblesRedaccion + politica de logs + revision periodica
CTO cuestiona seguridad en pre-ventaEsta matriz + SQL + tests + demo de aislamiento

Monitoreo minimo · metricas accionables

Metrica Uso Alerta
permission_denied_countDetectar problemas de config o ataquesSpike sostenido → Admin
failed_login_countSeguridad de cuentas> 5/min mismo usuario → bloqueo
sensitive_download_countDescargas evidencias/reportes> umbral diario → Director
ai_policy_violation_countSeguridad IA (FARO-AI-001)Cualquiera critica → Admin
api_key_usage_countIntegracionesIP nueva → Admin
rls_error_countSeguridad DBCualquier > 0 → Admin (deberia ser cero)
audit_log_volumeAuditoria saludableDrop abrupto → posible falla
score_recalculation_countGobierno ScoreCambio de modelo → Director

Roadmap · MVP a Enterprise

Fase 1 · RBAC base

Fundacion permisos

  • permissions
  • roles
  • role_permissions
  • user_roles
  • has_permission
  • seed roles + permisos
Fase 2 · RLS

Aislamiento por empresa

  • session helpers
  • apply RLS core tables
  • tests aislamiento
  • FORCE RLS criticas
Fase 3 · Auditoria

Trazabilidad

  • audit.audit_log
  • audit.log_event
  • auditEvent TS
  • 15 acciones sensibles
Fase 4 · Files / Reports

Seguridad de archivos

  • signed URLs
  • descarga auditada
  • reportes privados
  • storage policies
Fase 5 · IA

Gobierno transversal IA

  • ai_enabled empresa
  • ai_daily_budget_usd
  • ai audit cross
  • policy violations
Fase 6 · Admin UI

Panel administracion

  • roles editor
  • permisos editor
  • usuarios manager
  • api keys panel
  • audit log viewer
  • security events panel
Fase 7 · Enterprise

Posterior MVP

  • SSO SAML/OIDC
  • MFA obligatorio
  • ABAC avanzado
  • SCIM provisioning
  • SIEM integracion
  • retencion legal hold

Cross-references

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.