01 · Resumen ejecutivo

Multiempresa dia uno, RLS como segunda defensa

FARO Connect nace multiempresa. Aunque el primer piloto opere con un solo cliente (Empresa Demo Cuyo S.A.), el diseno soporta varios grupos, sucursales, areas, roles y niveles de acceso desde la primera migracion. La regla que define este documento: ninguna consulta operativa debe confiar solamente en el backend; la base tambien debe defenderse.

El piloto FARO Connect MVP implementa una capa de seguridad de siete defensas en serie:

  1. Frontend oculta lo que el usuario no deberia usar.
  2. Backend valida sesion, rol, payload y permisos.
  3. Backend setea contexto de sesion (app.company_id, app.user_id, app.role_codes) antes de ejecutar queries.
  4. PostgreSQL RLS filtra todas las filas por company_id = faro.current_company_id().
  5. Roles y permissions limitan que acciones puede ejecutar cada usuario.
  6. Vistas seguras reducen exposicion de columnas sensibles (margen, costo, datos PII).
  7. Auditoria registra todas las operaciones criticas en audit.audit_log.

La consecuencia practica: si un bug de backend arma mal un query, RLS sigue protegiendo los datos. Si un endpoint olvida filtrar por empresa, la base no devuelve filas de otros tenants. Tradicional, aburrido y correcto.

Cruza con matriz-raci-105.html: la matriz RACI define responsabilidad por proceso (quien aprueba el cierre de una accion, quien escala una tension). Este documento define permiso por modulo/tabla (que roles pueden leer la tabla faro.actions, cuales pueden escribir, cuales solo ven las propias). Ambos sistemas coexisten: RACI describe el flujo humano, RLS hace cumplir la regla tecnica.

La frase clasica que este documento previene: "primero hacemos simple, despues agregamos seguridad". Eso termina en cirugia mayor con anestesia escasa. FARO Connect incorpora seguridad desde el inicio aunque al principio se active en modo simple.

Pecado mortal de SaaS. Mezclar datos de clientes. No se arregla con diseno premium ni con una frase linda; se arregla con seguridad desde la base. Por eso multiempresa + RLS estan en la primera ola de migraciones (V014-V023), antes de seeds demo (V024+) y antes del motor evaluador (FARO-ENG-003).

02 · Tesis y principios

Diez principios que rigen toda la capa de seguridad

Antes de tocar una policy, leer estos principios. Romperlos no se nota en el commit; se nota cuando un auditor pregunta por que Tenant A pudo ver datos de Tenant B.

Principio 01

La base se defiende sola

RLS no reemplaza al backend; lo respalda. Si el backend olvida filtrar por company_id, PostgreSQL filtra igual. Cinturon y tirantes.

Principio 02

Multiempresa desde V014

No hay version "single-tenant" del piloto. Todas las tablas operativas llevan company_id NOT NULL y RLS desde el dia uno.

Principio 03

Contexto por request

Cada request abre transaccion, setea app.company_id / app.user_id / app.role_codes con set_config, ejecuta y libera. Sin contexto no se ven filas.

Principio 04

No superuser en aplicacion

La app se conecta como faro_app (rol controlado, NOINHERIT). Solo migraciones usan faro_migration. Superuser no aparece en runtime.

Principio 05

FORCE RLS en tablas criticas

RLS se aplica tambien al owner de la tabla. Evita el bypass clasico cuando el owner cae en una migracion mal corrida.

Principio 06

DELETE no, archive si

Para MVP no se permite DELETE fisico desde aplicacion. Patron: status = archived, deleted_at = now(), auditoria. Trazabilidad protegida.

Principio 07

Roles funcionales no son DB roles

faro.roles guarda roles funcionales (director, area_manager). PostgreSQL solo conoce faro_app. La separacion permite mover negocio sin tocar la DB.

Principio 08

Vistas para columnas sensibles

RLS filtra filas. Lo que filtra columnas son vistas: v_sales_public sin margen para vendedores, v_sales_executive con margen para direccion.

Principio 09

IA bajo el mismo contexto

La IA explica y resume datos ya autorizados; nunca consulta tablas crudas sin RLS. La IA explica, no rompe gobierno de datos.

Principio 10

Test negativo obligatorio

V023 corre dos tenants y verifica que A no ve B y B no ve A. Sin test negativo, no hay garantia de aislamiento. Solo hay esperanza.

Regla madre. Si un endpoint puede devolver datos sin que el backend haya seteado app.company_id, esa funcion esta rota. 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 para piloto.

03 · Arquitectura de seguridad

Siete capas en serie, no en paralelo

El usuario llega autenticado por el frontend, cruza el backend, dispara queries que pasan por RLS, ejecuta acciones limitadas por permisos y deja huella en auditoria. Cada capa tiene su responsabilidad y ninguna reemplaza a la siguiente.

Capa 01
Frontend
UI condicional segun rol. Oculta botones, secciones y exportaciones que el usuario no deberia usar.
Mostrar solo lo permitido
Capa 02
Backend · sesion y payload
Valida JWT/cookie, verifica rol funcional, normaliza payload, chequea permiso del endpoint.
Autenticacion + autorizacion
Capa 03
Backend · contexto de sesion
Antes de ejecutar queries, abre transaccion y setea set_config('app.company_id', ...), app.user_id, app.role_codes.
Inyecta contexto en PG
Capa 04
PostgreSQL · RLS policies
Cada SELECT/INSERT/UPDATE se evalua contra company_id = faro.current_company_id(). Si el contexto no esta seteado, no hay filas.
Aislamiento por tenant
Capa 05
PostgreSQL · roles y permissions
Las policies tambien chequean faro.is_executive_role(), faro.current_user_has_permission('tensions.manage'), etc.
Limita lectura y escritura
Capa 06
Auditoria · audit.audit_log
Triggers en tablas criticas (tensions, actions, evidence, reports, users) registran INSERT/UPDATE/DELETE con actor y company_id.
Trazabilidad legal
Capa 07
Reportes + IA
Generacion de PDF y enriquecimiento IA consumen datos ya filtrados por RLS. Nunca acceden a tablas crudas sin contexto.
No mezclan empresas

Lectura horizontal. Las capas rojas son PostgreSQL (defensa de la base), la capa verde es auditoria, la capa ambar es IA y reportes. Las primeras tres son backend tradicional.

04 · 13 roles base MVP

Roles funcionales que viven en faro.roles

Estos 13 roles se seedean en V016__seed_roles_permissions.sql con company_id = NULL (roles de sistema). Cada empresa puede luego asignar usuarios a los roles via faro.user_roles. Los roles marcados con borde coral son de sistema FARO (no aplicables a empresa cliente).

faro_owner System

Owner FARO

Control interno maximo FARO. Acceso lectura global a todas las empresas para soporte y debugging.

Ejemplo: Tomas Pombo, soporte L3 FARO. Bypass de aislamiento via has_role('faro_owner') en policies de companies.

integration_service Service

Servicio integracion

Carga datos automatica (ETL, cron, jobs). Sin UI, sin sesion humana. Lee y escribe RAW, staging, facts y master.

Ejemplo: worker que sube CSV de ventas semanal desde Tango. Setea contexto con role_codes = 'integration_service'.

company_admin Company

Administrador empresa

Administra usuarios, fuentes y configuracion de su empresa. Puede gestionar permisos y aprobar reglas. No es ejecutivo.

Ejemplo: CTO de Empresa Demo Cuyo S.A. configura conectores Tango y onboarda directivos.

director Company

Director

Ve informacion ejecutiva completa de la empresa. Acceso lectura a facts, KPIs, tensiones, acciones y reportes. Recibe auditoria parcial.

Ejemplo: director general de Empresa Demo Cuyo S.A. abre la bandeja de tensiones cada lunes.

general_manager Company

Gerente General

Ve operacion integral y gestiona acciones (manage). Aprueba reglas comerciales, escala tensiones criticas, asigna responsables.

Ejemplo: gerente general aprueba cierre de TNS-002 (descuento fuera de politica) con evidencia adjunta.

area_manager Area

Responsable de Area

Ve datos y acciones de su area asignada (comercial, finanzas, stock, compras, RRHH). Cierra acciones asignadas.

Ejemplo: jefe comercial ve fact_sales filtrado por area, cierra ACT-COM-001 tras revisar descuentos.

branch_manager Branch

Responsable Sucursal

Ve datos y acciones de su sucursal asignada. Acceso lectura a facts filtrados por branch_id. Maneja sus acciones.

Ejemplo: encargado de sucursal Mendoza ve ventas y stock de la sucursal, no del resto del grupo.

finance_user Area

Usuario Finanzas

Ve cobranza, caja, fact_receivables y reportes financieros. Maneja acciones financieras (TNS-004, TNS-005).

Ejemplo: analista cobranza gestiona mora de cliente critico desde la bandeja filtrada por dominio financiero.

commercial_user Area

Usuario Comercial

Ve ventas, clientes y acciones comerciales. Acceso a v_sales_public (sin margen) salvo permiso explicito.

Ejemplo: vendedor consulta su cartera de clientes y revisa acciones asignadas relacionadas con descuento.

stock_user Area

Usuario Stock

Ve inventario, quiebres, reposicion. Maneja acciones de stock (TNS-006, TNS-007, TNS-008, TNS-025).

Ejemplo: jefe deposito revisa fact_stock_snapshots y cierra accion de reposicion ACT-STK-002.

hr_user Area

Usuario RRHH

Ve RRHH si esta habilitado. Acceso a faro.employees y reportes laborales. Empleado individual ve su propio registro.

Ejemplo: responsable RRHH consulta empleados activos para asignar como responsables de acciones.

viewer Company

Lector

Solo lectura limitada. Acceso parcial a reportes, KPIs y tensiones. No modifica, no exporta datos crudos.

Ejemplo: socio inversor ve evolucion FARO Score y reportes mensuales sin acceso operativo.

external_auditor Company

Auditor externo

Lectura auditada y restringida. Acceso a audit.audit_log + lectura de facts y reports. Sus consultas se registran adicionalmente.

Ejemplo: auditor contable externo revisa trazabilidad de tensiones financieras cerradas en el trimestre.

Diferencia con DB roles. PostgreSQL solo conoce cuatro roles: faro_app (aplicacion), faro_migration (migraciones), faro_readonly (BI / analytics), faro_ingestion (workers ETL). Los 13 roles funcionales viven en faro.roles y se evaluan via funciones helper (faro.has_role('director')). Esta separacion permite agregar nuevos roles de negocio sin tocar grants de PostgreSQL.

05 · 6 scopes

Niveles de alcance que aplica cada rol

Un rol no solo describe que puede hacer; tambien describe sobre que conjunto de datos. El scope acota la vision: system es todo FARO, company es la empresa, branch es la sucursal, area es el dominio funcional, own son registros propios y service es tecnico sin UI.

system Capa FARO interna

Nivel FARO interno

Trasciende empresas. Soporte L3 y owner FARO. Bypass explicito en policies (OR has_role('faro_owner')).

Rol que lo usa: faro_owner. Riesgo: debe minimizarse a 1-2 personas y auditarse.

company Empresa cliente

Toda la empresa cliente

Ve todos los datos de su empresa (filtrado por company_id de la sesion). Es el scope ejecutivo por defecto.

Roles que lo usan: director, general_manager, company_admin, viewer, external_auditor.

branch Sucursal

Solo sucursal asignada

Ve datos de su sucursal segun user_roles.branch_id. Acceso lateral a otras sucursales bloqueado.

Rol que lo usa: branch_manager. Patron: futura policy con user_has_branch_access().

area Dominio funcional

Solo area asignada

Ve datos de su area (comercial, finanzas, stock, compras, RRHH). Acceso cruzado bloqueado.

Roles que lo usan: area_manager, finance_user, commercial_user, stock_user, hr_user.

own Asignacion personal

Solo registros propios o asignados

Ve unicamente filas donde el usuario es responsible_user_id, approver_user_id o submitted_by.

Uso: empleado individual ve sus acciones, sus evidencias y sus notificaciones. Nada mas.

service Tecnico sin UI

Servicio tecnico sin UI

Acceso desde workers, jobs y cron. Sin sesion humana. Permisos amplios pero acotados a su empresa contratada.

Rol que lo usa: integration_service. Patron: setea company_id del cliente al iniciar batch.

06 · Matriz de permisos 16x7

Que puede hacer cada rol en cada modulo

Cruz de 16 modulos por 7 roles ejecutivos y tecnicos. La matriz traduce los principios a una grilla operativa: Admin = lectura y escritura completa; Manage = gestion de workflow; Read = solo lectura; Area/Branch = lectura filtrada por scope; Assigned = solo registros propios; Write tecnico = solo via integration_service.

Cruz con matriz-raci-105.html. Esta matriz describe permisos por modulo/tabla SQL. La matriz RACI 105 describe responsabilidad por proceso de negocio (quien aprueba el cierre de TNS-002, quien escala TNS-005). Las dos son complementarias: RLS hace cumplir el permiso tecnico, RACI ordena la conversacion humana.

Modulo company_admin director general_manager area_manager branch_manager viewer integration_service
Empresas Admin Read Read No No No No
Usuarios Admin Read Read No No No No
Fuentes Admin Read Read No No Parcial Write
RAW Read No No No No No Write
Staging Read No No No No No Write
Maestros Admin Read Read Parcial Parcial Parcial Write
Facts ventas Read Read Read Area Branch Parcial Write
Facts cobranza Read Read Read Finanzas No/parcial No Write
KPIs Read Read Read Area Branch Parcial No
Senales Read Read Read Area Branch Parcial No
Tensiones Admin Read Manage Asig/Area Branch Parcial No
Acciones Admin Read Manage Asig/Area Branch Parcial No
Evidencia Admin Read Manage Assigned Assigned Parcial No
Score Read Read Read Area Branch Parcial No
Reportes Admin Read Read Area Branch Parcial No
Auditoria Read Parcial No/parcial No No No No

Notas de lectura. "Area" = lectura filtrada por area_id del usuario. "Branch" = lectura filtrada por branch_id. "Assigned" = solo registros donde responsible_user_id = current_user_id(). "Manage" = lectura + cambio de estado + asignacion + cierre (no eliminacion). "Admin" = lectura + escritura + configuracion completa. "Write tecnico" = solo via integration_service, no via UI.

07 · V014 · Security context functions

Funciones helper que leen el contexto de sesion

Estas funciones se crean en V014__security_context_functions.sql y son la base de toda policy RLS posterior. Leen el contexto desde current_setting('app.company_id', true), app.user_id y app.role_codes. Si el contexto no esta seteado, devuelven NULL o false, lo que hace que ninguna policy permita acceso.

▸ V014__security_context_functions.sql
-- ============================================================
-- FARO-SQL-002 · V014__security_context_functions.sql
-- Session context helper functions
-- ============================================================

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
    RETURN NULL;
  END IF;

  RETURN v_company_id::uuid;
EXCEPTION
  WHEN invalid_text_representation THEN
    RETURN NULL;
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;
EXCEPTION
  WHEN invalid_text_representation THEN
    RETURN NULL;
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 v_roles = '' THEN
    RETURN ARRAY[]::text[];
  END IF;

  RETURN string_to_array(v_roles, ',');
END;
$$ LANGUAGE plpgsql STABLE;

CREATE OR REPLACE FUNCTION faro.has_role(p_role_code text)
RETURNS boolean AS $$
BEGIN
  RETURN p_role_code = ANY(faro.current_role_codes());
END;
$$ LANGUAGE plpgsql STABLE;

CREATE OR REPLACE FUNCTION faro.has_any_role(p_role_codes text[])
RETURNS boolean AS $$
BEGIN
  RETURN faro.current_role_codes() && p_role_codes;
END;
$$ LANGUAGE plpgsql STABLE;

CREATE OR REPLACE FUNCTION faro.is_service_role()
RETURNS boolean AS $$
BEGIN
  RETURN faro.has_any_role(ARRAY['integration_service', 'faro_owner']);
END;
$$ LANGUAGE plpgsql STABLE;

CREATE OR REPLACE FUNCTION faro.is_company_admin()
RETURNS boolean AS $$
BEGIN
  RETURN faro.has_any_role(ARRAY['company_admin', 'faro_owner']);
END;
$$ LANGUAGE plpgsql STABLE;

CREATE OR REPLACE FUNCTION faro.is_executive_role()
RETURNS boolean AS $$
BEGIN
  RETURN faro.has_any_role(ARRAY['director', 'general_manager', 'company_admin', 'faro_owner']);
END;
$$ LANGUAGE plpgsql STABLE;

COMMENT ON FUNCTION faro.current_company_id() IS 'Returns current tenant/company id from app.company_id session setting';
COMMENT ON FUNCTION faro.current_user_id() IS 'Returns current user id from app.user_id session setting';
COMMENT ON FUNCTION faro.current_role_codes() IS 'Returns role codes from app.role_codes session setting';
Como debe setear el backend el contexto (ejemplo Node.js)
▸ TypeScript · middleware conceptual
await client.query('BEGIN');

await client.query(
  `SELECT set_config('app.company_id', $1, true)`,
  [session.companyId]
);

await client.query(
  `SELECT set_config('app.user_id', $1, true)`,
  [session.userId]
);

await client.query(
  `SELECT set_config('app.role_codes', $1, true)`,
  [session.roleCodes.join(',')]
);

const result = await client.query(`
  SELECT *
  FROM faro.tensions
  ORDER BY detected_at DESC
`);

await client.query('COMMIT');

Advertencia. No se debe usar una conexion pooled sin resetear contexto. Si se usa pool, cada request debe: abrir transaccion, setear contexto, ejecutar queries, commit/rollback, liberar conexion. Si no se hace, se arrastra contexto de otro usuario. Eso no es bug menor; es incendio.

08 · V015 · Application DB roles + grants

Cuatro roles de PostgreSQL, no trece

PostgreSQL solo conoce cuatro roles tecnicos: faro_app (la aplicacion real), faro_migration (corre Flyway/migrations), faro_readonly (BI y analytics), faro_ingestion (workers ETL). Los 13 roles funcionales viven en faro.roles y se evaluan via funciones helper. Esta separacion permite agregar roles de negocio sin tocar GRANT.

▸ V015__application_db_roles_and_grants.sql
-- ============================================================
-- FARO-SQL-002 · V015__application_db_roles_and_grants.sql
-- PostgreSQL roles and grants
-- ============================================================

DO $$
BEGIN
  IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'faro_app') THEN
    CREATE ROLE faro_app NOINHERIT;
  END IF;

  IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'faro_migration') THEN
    CREATE ROLE faro_migration NOINHERIT;
  END IF;

  IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'faro_readonly') THEN
    CREATE ROLE faro_readonly NOINHERIT;
  END IF;

  IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'faro_ingestion') THEN
    CREATE ROLE faro_ingestion NOINHERIT;
  END IF;
END $$;

GRANT USAGE ON SCHEMA faro TO faro_app;
GRANT USAGE ON SCHEMA audit TO faro_app;

GRANT SELECT, INSERT, UPDATE ON ALL TABLES IN SCHEMA faro TO faro_app;
GRANT INSERT ON audit.audit_log TO faro_app;

GRANT USAGE ON ALL SEQUENCES IN SCHEMA faro TO faro_app;

GRANT USAGE ON SCHEMA faro TO faro_readonly;
GRANT SELECT ON ALL TABLES IN SCHEMA faro TO faro_readonly;

GRANT USAGE ON SCHEMA faro TO faro_ingestion;
GRANT SELECT, INSERT, UPDATE ON faro.data_sources TO faro_ingestion;
GRANT SELECT, INSERT, UPDATE ON faro.ingestion_batches TO faro_ingestion;
GRANT SELECT, INSERT, UPDATE ON faro.raw_records TO faro_ingestion;
GRANT SELECT, INSERT, UPDATE ON faro.source_fields TO faro_ingestion;

ALTER DEFAULT PRIVILEGES IN SCHEMA faro
GRANT SELECT, INSERT, UPDATE ON TABLES TO faro_app;

ALTER DEFAULT PRIVILEGES IN SCHEMA faro
GRANT SELECT ON TABLES TO faro_readonly;

Por que NOINHERIT. Si faro_app tuviera INHERIT, podria heredar permisos de roles "padre" sin pedir explicitamente. NOINHERIT obliga a usar SET ROLE manual y deja el rol cerrado por defecto. Es la postura segura para una app multitenant.

09 · V016 · Seed roles + permissions

Seed idempotente de 13 roles + 28 permisos base

El seed se ejecuta con ON CONFLICT DO NOTHING sobre (company_id, role_code) y permission_code, lo que lo hace idempotente: correr la migracion dos veces no duplica filas. Los roles de sistema (faro_owner, integration_service) llevan company_id = NULL; los roles funcionales se materializan luego en cada empresa via faro.user_roles.

▸ V016__seed_roles_permissions.sql · roles base
-- ============================================================
-- FARO-SQL-002 · V016__seed_roles_permissions.sql
-- Functional roles and permissions
-- ============================================================

-- This seed assumes companies already exist.
-- System roles may have company_id NULL.

INSERT INTO faro.roles (
  company_id,
  role_code,
  name,
  scope,
  description,
  is_system_role
)
VALUES
  (NULL, 'faro_owner', 'Owner FARO', 'system', 'Internal FARO owner role', true),
  (NULL, 'integration_service', 'Integration Service', 'service', 'Technical integration role', true),
  (NULL, 'company_admin', 'Administrador Empresa', 'company', 'Company administrator', true),
  (NULL, 'director', 'Director', 'company', 'Executive director', true),
  (NULL, 'general_manager', 'Gerente General', 'company', 'General manager', true),
  (NULL, 'area_manager', 'Responsable de Area', 'area', 'Area manager', true),
  (NULL, 'branch_manager', 'Responsable de Sucursal', 'branch', 'Branch manager', true),
  (NULL, 'finance_user', 'Usuario Finanzas', 'area', 'Finance user', true),
  (NULL, 'commercial_user', 'Usuario Comercial', 'area', 'Commercial user', true),
  (NULL, 'stock_user', 'Usuario Stock', 'area', 'Stock user', true),
  (NULL, 'hr_user', 'Usuario RRHH', 'area', 'HR user', true),
  (NULL, 'viewer', 'Lector', 'company', 'Read-only limited user', true),
  (NULL, 'external_auditor', 'Auditor Externo', 'company', 'External auditor', true)
ON CONFLICT (company_id, role_code) DO NOTHING;
▸ V016__seed_roles_permissions.sql · 28 permisos
INSERT INTO faro.permissions (permission_code, module_code, action_code, description)
VALUES
  ('users.read', 'users', 'read', 'Read users'),
  ('users.manage', 'users', 'manage', 'Manage users'),
  ('roles.read', 'roles', 'read', 'Read roles'),
  ('roles.manage', 'roles', 'manage', 'Manage roles'),
  ('sources.read', 'sources', 'read', 'Read data sources'),
  ('sources.manage', 'sources', 'manage', 'Manage data sources'),
  ('raw.read', 'raw', 'read', 'Read RAW data'),
  ('raw.write', 'raw', 'write', 'Write RAW data'),
  ('staging.read', 'staging', 'read', 'Read staging data'),
  ('staging.write', 'staging', 'write', 'Write staging data'),
  ('facts.read', 'facts', 'read', 'Read facts'),
  ('facts.write', 'facts', 'write', 'Write facts'),
  ('kpis.read', 'kpis', 'read', 'Read KPIs'),
  ('signals.read', 'signals', 'read', 'Read signals'),
  ('rules.read', 'rules', 'read', 'Read rules'),
  ('rules.manage', 'rules', 'manage', 'Manage rules'),
  ('tensions.read', 'tensions', 'read', 'Read tensions'),
  ('tensions.manage', 'tensions', 'manage', 'Manage tensions'),
  ('actions.read', 'actions', 'read', 'Read actions'),
  ('actions.manage', 'actions', 'manage', 'Manage actions'),
  ('evidence.read', 'evidence', 'read', 'Read evidence'),
  ('evidence.upload', 'evidence', 'upload', 'Upload evidence'),
  ('evidence.review', 'evidence', 'review', 'Review evidence'),
  ('score.read', 'score', 'read', 'Read score'),
  ('reports.read', 'reports', 'read', 'Read reports'),
  ('reports.generate', 'reports', 'generate', 'Generate reports'),
  ('audit.read', 'audit', 'read', 'Read audit logs'),
  ('admin.full', 'admin', 'full', 'Full administration')
ON CONFLICT (permission_code) DO NOTHING;
▸ V016 · funcion de validacion de permiso funcional
CREATE OR REPLACE FUNCTION faro.current_user_has_permission(p_permission_code text)
RETURNS boolean AS $$
DECLARE
  v_user_id uuid;
  v_company_id uuid;
  v_has_permission boolean;
BEGIN
  v_user_id := faro.current_user_id();
  v_company_id := faro.current_company_id();

  IF v_user_id IS NULL OR v_company_id IS NULL THEN
    RETURN false;
  END IF;

  SELECT EXISTS (
    SELECT 1
    FROM faro.user_roles ur
    JOIN faro.role_permissions rp
      ON rp.role_id = ur.role_id
    JOIN faro.permissions p
      ON p.permission_id = rp.permission_id
    WHERE ur.user_id = v_user_id
      AND ur.company_id = v_company_id
      AND ur.status = 'active'
      AND p.permission_code = p_permission_code
  )
  INTO v_has_permission;

  RETURN COALESCE(v_has_permission, false);
END;
$$ LANGUAGE plpgsql STABLE;
10 · V017-V021 · Enable RLS + policies por dominio

RLS activo en 33 tablas, policies por dominio

La activacion se hace en cuatro migraciones segun dominio: V017 activa RLS en todas las tablas operativas, V018 aplica policies a organizacion/sources/RAW, V019 a master/staging/facts, V020 a KPIs/rules/tensions/actions/evidence, V021 a score/reports/notifications. Patron general: SELECT filtra por company_id = current_company_id(); INSERT y UPDATE chequean ademas rol o permiso; DELETE no se permite (soft delete via status = archived).

V017 · Enable Row Level Security

▸ V017__enable_rls_core_tables.sql · activar RLS + FORCE
-- Enable RLS en 33 tablas operativas
ALTER TABLE faro.companies ENABLE ROW LEVEL SECURITY;
ALTER TABLE faro.branches ENABLE ROW LEVEL SECURITY;
ALTER TABLE faro.areas ENABLE ROW LEVEL SECURITY;
ALTER TABLE faro.users ENABLE ROW LEVEL SECURITY;
ALTER TABLE faro.user_roles ENABLE ROW LEVEL SECURITY;
ALTER TABLE faro.raci_assignments ENABLE ROW LEVEL SECURITY;

ALTER TABLE faro.data_sources ENABLE ROW LEVEL SECURITY;
ALTER TABLE faro.source_fields ENABLE ROW LEVEL SECURITY;
ALTER TABLE faro.ingestion_batches ENABLE ROW LEVEL SECURITY;
ALTER TABLE faro.raw_records ENABLE ROW LEVEL SECURITY;

ALTER TABLE faro.customers ENABLE ROW LEVEL SECURITY;
ALTER TABLE faro.products ENABLE ROW LEVEL SECURITY;
ALTER TABLE faro.suppliers ENABLE ROW LEVEL SECURITY;
ALTER TABLE faro.employees ENABLE ROW LEVEL SECURITY;

ALTER TABLE faro.staging_sales ENABLE ROW LEVEL SECURITY;
ALTER TABLE faro.staging_stock ENABLE ROW LEVEL SECURITY;
ALTER TABLE faro.staging_receivables ENABLE ROW LEVEL SECURITY;
ALTER TABLE faro.staging_purchases ENABLE ROW LEVEL SECURITY;

ALTER TABLE faro.fact_sales ENABLE ROW LEVEL SECURITY;
ALTER TABLE faro.fact_stock_snapshots ENABLE ROW LEVEL SECURITY;
ALTER TABLE faro.fact_receivables ENABLE ROW LEVEL SECURITY;
ALTER TABLE faro.fact_purchases ENABLE ROW LEVEL SECURITY;

ALTER TABLE faro.kpi_snapshots ENABLE ROW LEVEL SECURITY;
ALTER TABLE faro.signal_log ENABLE ROW LEVEL SECURITY;
ALTER TABLE faro.rule_definitions ENABLE ROW LEVEL SECURITY;
ALTER TABLE faro.rule_evaluations ENABLE ROW LEVEL SECURITY;

ALTER TABLE faro.tensions ENABLE ROW LEVEL SECURITY;
ALTER TABLE faro.actions ENABLE ROW LEVEL SECURITY;
ALTER TABLE faro.evidence ENABLE ROW LEVEL SECURITY;
ALTER TABLE faro.action_status_history ENABLE ROW LEVEL SECURITY;

ALTER TABLE faro.score_snapshots ENABLE ROW LEVEL SECURITY;
ALTER TABLE faro.reports ENABLE ROW LEVEL SECURITY;
ALTER TABLE faro.notifications ENABLE ROW LEVEL SECURITY;

-- FORCE RLS: aplica RLS al owner de la tabla.
-- Usar con cuidado en produccion tras definir estrategia de migracion.
ALTER TABLE faro.companies FORCE ROW LEVEL SECURITY;
ALTER TABLE faro.users FORCE ROW LEVEL SECURITY;
ALTER TABLE faro.fact_sales FORCE ROW LEVEL SECURITY;
ALTER TABLE faro.tensions FORCE ROW LEVEL SECURITY;
ALTER TABLE faro.actions FORCE ROW LEVEL SECURITY;
ALTER TABLE faro.evidence FORCE ROW LEVEL SECURITY;
ALTER TABLE faro.score_snapshots FORCE ROW LEVEL SECURITY;
ALTER TABLE faro.reports FORCE ROW LEVEL SECURITY;

V018 · Policies organizacion + sources + RAW

Patron base: SELECT por company_id; WRITE solo si tambien is_company_admin() o has_role('integration_service'). RAW queda doblemente restringido porque expone payload tecnico.

▸ V018__rls_policies_org_sources_raw.sql · extracto representativo
-- Companies: el usuario solo ve su empresa (faro_owner bypass).
DROP POLICY IF EXISTS company_isolation_companies_select ON faro.companies;
CREATE POLICY company_isolation_companies_select
ON faro.companies
FOR SELECT
TO faro_app
USING (
  company_id = faro.current_company_id()
  OR faro.has_role('faro_owner')
);

-- Branches: lectura por empresa; escritura solo company_admin.
DROP POLICY IF EXISTS company_isolation_branches_write ON faro.branches;
CREATE POLICY company_isolation_branches_write
ON faro.branches
FOR ALL
TO faro_app
USING (
  company_id = faro.current_company_id()
  AND faro.is_company_admin()
)
WITH CHECK (
  company_id = faro.current_company_id()
  AND faro.is_company_admin()
);

-- Areas, Users, User_roles, Raci, Data sources, Source fields
-- siguen el mismo patron (omitido por brevedad; ver migracion fuente).

-- Data sources: company_admin O integration_service pueden escribir.
DROP POLICY IF EXISTS company_isolation_data_sources_write ON faro.data_sources;
CREATE POLICY company_isolation_data_sources_write
ON faro.data_sources
FOR ALL
TO faro_app
USING (
  company_id = faro.current_company_id()
  AND (
    faro.is_company_admin()
    OR faro.has_role('integration_service')
  )
)
WITH CHECK (
  company_id = faro.current_company_id()
  AND (
    faro.is_company_admin()
    OR faro.has_role('integration_service')
  )
);

-- RAW records: doble restriccion (rol tecnico O permiso raw.read).
DROP POLICY IF EXISTS company_isolation_raw_records_select ON faro.raw_records;
CREATE POLICY company_isolation_raw_records_select
ON faro.raw_records
FOR SELECT
TO faro_app
USING (
  company_id = faro.current_company_id()
  AND (
    faro.has_role('integration_service')
    OR faro.is_company_admin()
    OR faro.current_user_has_permission('raw.read')
  )
);

DROP POLICY IF EXISTS company_isolation_raw_records_write ON faro.raw_records;
CREATE POLICY company_isolation_raw_records_write
ON faro.raw_records
FOR INSERT
TO faro_app
WITH CHECK (
  company_id = faro.current_company_id()
  AND (
    faro.has_role('integration_service')
    OR faro.current_user_has_permission('raw.write')
    OR faro.is_company_admin()
  )
);

V019 · Policies master + staging + facts

Master data (customers, products, suppliers): lectura por empresa, escritura via admin o service. Empleados (PII): lectura solo si ejecutivo, RRHH, admin, service o el propio empleado. Staging: solo admin/service. Facts: lectura por scope funcional.

▸ V019__rls_policies_master_staging_facts.sql · extracto
-- Empleados (PII): doble filtro empresa + rol/permiso/propio registro.
DROP POLICY IF EXISTS company_isolation_employees_select ON faro.employees;
CREATE POLICY company_isolation_employees_select
ON faro.employees
FOR SELECT
TO faro_app
USING (
  company_id = faro.current_company_id()
  AND (
    faro.is_executive_role()
    OR faro.has_any_role(ARRAY['hr_user', 'company_admin', 'integration_service'])
    OR user_id = faro.current_user_id()
  )
);

-- Staging sales (mismo patron para stock / receivables / purchases).
DROP POLICY IF EXISTS company_isolation_staging_sales_all ON faro.staging_sales;
CREATE POLICY company_isolation_staging_sales_all
ON faro.staging_sales
FOR ALL
TO faro_app
USING (
  company_id = faro.current_company_id()
  AND (
    faro.has_role('integration_service')
    OR faro.is_company_admin()
    OR faro.current_user_has_permission('staging.read')
  )
)
WITH CHECK (
  company_id = faro.current_company_id()
  AND (
    faro.has_role('integration_service')
    OR faro.is_company_admin()
    OR faro.current_user_has_permission('staging.write')
  )
);

-- Facts ventas: lectura para ejecutivos, comercial, branch_manager, area_manager.
DROP POLICY IF EXISTS company_isolation_fact_sales_select ON faro.fact_sales;
CREATE POLICY company_isolation_fact_sales_select
ON faro.fact_sales
FOR SELECT
TO faro_app
USING (
  company_id = faro.current_company_id()
  AND (
    faro.is_executive_role()
    OR faro.has_any_role(ARRAY['commercial_user', 'branch_manager', 'area_manager'])
    OR faro.current_user_has_permission('facts.read')
  )
);

-- Facts cobranza: rol finanzas, ejecutivo o permiso facts.read.
DROP POLICY IF EXISTS company_isolation_fact_receivables_select ON faro.fact_receivables;
CREATE POLICY company_isolation_fact_receivables_select
ON faro.fact_receivables
FOR SELECT
TO faro_app
USING (
  company_id = faro.current_company_id()
  AND (
    faro.is_executive_role()
    OR faro.has_role('finance_user')
    OR faro.current_user_has_permission('facts.read')
  )
);

-- Facts stock + purchases siguen el mismo patron (stock_user / area_manager).

V020 · Policies KPIs + signals + rules + tensions + actions + evidence

Aqui aparece el patron responsible_user_id = current_user_id() para que el responsable de una tension pueda verla y editarla aunque no sea ejecutivo. Las rules tienen una excepcion: las reglas globales con company_id NULL son visibles para todos (las distribuye FARO).

▸ V020__rls_policies_kpis_rules_tensions_actions.sql · extracto
-- Rule definitions: globales (company_id NULL) + por empresa.
DROP POLICY IF EXISTS rule_definitions_select_policy ON faro.rule_definitions;
CREATE POLICY rule_definitions_select_policy
ON faro.rule_definitions
FOR SELECT
TO faro_app
USING (
  company_id IS NULL
  OR company_id = faro.current_company_id()
);

DROP POLICY IF EXISTS rule_definitions_write_policy ON faro.rule_definitions;
CREATE POLICY rule_definitions_write_policy
ON faro.rule_definitions
FOR ALL
TO faro_app
USING (
  (
    company_id = faro.current_company_id()
    AND faro.is_company_admin()
  )
  OR faro.has_role('faro_owner')
)
WITH CHECK (
  (
    company_id = faro.current_company_id()
    AND faro.is_company_admin()
  )
  OR faro.has_role('faro_owner')
);

-- Tensions: ejecutivo O responsable O permiso tensions.read O area/branch manager.
DROP POLICY IF EXISTS company_isolation_tensions_select ON faro.tensions;
CREATE POLICY company_isolation_tensions_select
ON faro.tensions
FOR SELECT
TO faro_app
USING (
  company_id = faro.current_company_id()
  AND (
    faro.is_executive_role()
    OR responsible_user_id = faro.current_user_id()
    OR faro.current_user_has_permission('tensions.read')
    OR faro.has_any_role(ARRAY['area_manager', 'branch_manager'])
  )
);

-- Tensions UPDATE: solo ejecutivo, responsable o quien tiene tensions.manage.
DROP POLICY IF EXISTS company_isolation_tensions_update ON faro.tensions;
CREATE POLICY company_isolation_tensions_update
ON faro.tensions
FOR UPDATE
TO faro_app
USING (
  company_id = faro.current_company_id()
  AND (
    faro.is_executive_role()
    OR responsible_user_id = faro.current_user_id()
    OR faro.current_user_has_permission('tensions.manage')
  )
)
WITH CHECK (
  company_id = faro.current_company_id()
  AND (
    faro.is_executive_role()
    OR responsible_user_id = faro.current_user_id()
    OR faro.current_user_has_permission('tensions.manage')
  )
);

-- Actions: ejecutivo O responsable O approver O permiso O area/branch manager.
DROP POLICY IF EXISTS company_isolation_actions_select ON faro.actions;
CREATE POLICY company_isolation_actions_select
ON faro.actions
FOR SELECT
TO faro_app
USING (
  company_id = faro.current_company_id()
  AND (
    faro.is_executive_role()
    OR responsible_user_id = faro.current_user_id()
    OR approver_user_id = faro.current_user_id()
    OR faro.current_user_has_permission('actions.read')
    OR faro.has_any_role(ARRAY['area_manager', 'branch_manager'])
  )
);

-- Evidence: ejecutivo O submitted_by O reviewed_by O permiso evidence.read.
DROP POLICY IF EXISTS company_isolation_evidence_select ON faro.evidence;
CREATE POLICY company_isolation_evidence_select
ON faro.evidence
FOR SELECT
TO faro_app
USING (
  company_id = faro.current_company_id()
  AND (
    faro.is_executive_role()
    OR submitted_by = faro.current_user_id()
    OR reviewed_by = faro.current_user_id()
    OR faro.current_user_has_permission('evidence.read')
  )
);

V021 · Policies score + reports + notifications

Score y reports siguen el patron de lectura por rol/scope. Notifications es especial: cada usuario solo ve sus propias notificaciones (user_id = current_user_id()) excepto ejecutivos que ven todas.

▸ V021__rls_policies_score_reports_notifications.sql · extracto
-- Score snapshots: lectura por empresa; escritura solo service o admin.
DROP POLICY IF EXISTS company_isolation_score_snapshots_select ON faro.score_snapshots;
CREATE POLICY company_isolation_score_snapshots_select
ON faro.score_snapshots
FOR SELECT
TO faro_app
USING (
  company_id = faro.current_company_id()
);

-- Reports: lectura por rol/permiso; escritura por ejecutivo o service o permiso.
DROP POLICY IF EXISTS company_isolation_reports_select ON faro.reports;
CREATE POLICY company_isolation_reports_select
ON faro.reports
FOR SELECT
TO faro_app
USING (
  company_id = faro.current_company_id()
  AND (
    faro.is_executive_role()
    OR faro.current_user_has_permission('reports.read')
    OR faro.has_any_role(ARRAY['area_manager', 'branch_manager', 'viewer'])
  )
);

-- Notifications: cada usuario ve solo las suyas (salvo ejecutivos).
DROP POLICY IF EXISTS company_isolation_notifications_select ON faro.notifications;
CREATE POLICY company_isolation_notifications_select
ON faro.notifications
FOR SELECT
TO faro_app
USING (
  company_id = faro.current_company_id()
  AND (
    user_id = faro.current_user_id()
    OR faro.is_executive_role()
  )
);

DROP POLICY IF EXISTS company_isolation_notifications_write ON faro.notifications;
CREATE POLICY company_isolation_notifications_write
ON faro.notifications
FOR ALL
TO faro_app
USING (
  company_id = faro.current_company_id()
  AND (
    faro.has_role('integration_service')
    OR faro.is_executive_role()
  )
)
WITH CHECK (
  company_id = faro.current_company_id()
  AND (
    faro.has_role('integration_service')
    OR faro.is_executive_role()
  )
);

Checklist · 33 tablas con RLS activo

Tabla RLS Politica base
faro.companiesSicompany_id (faro_owner bypass)
faro.branchesSicompany_id
faro.areasSicompany_id
faro.usersSicompany_id
faro.user_rolesSicompany_id
faro.raci_assignmentsSicompany_id
faro.data_sourcesSicompany_id + admin/service
faro.source_fieldsSicompany_id + admin/service
faro.ingestion_batchesSicompany_id + rol tecnico
faro.raw_recordsSicompany_id + raw.read/write
faro.customersSicompany_id
faro.productsSicompany_id
faro.suppliersSicompany_id
faro.employeesSicompany_id + rol/propio
faro.staging_salesSicompany_id + admin/service
faro.staging_stockSicompany_id + admin/service
faro.staging_receivablesSicompany_id + admin/service
faro.staging_purchasesSicompany_id + admin/service
faro.fact_salesSicompany_id + scope comercial
faro.fact_stock_snapshotsSicompany_id + scope stock
faro.fact_receivablesSicompany_id + scope finanzas
faro.fact_purchasesSicompany_id + scope compras
faro.kpi_snapshotsSicompany_id
faro.signal_logSicompany_id
faro.rule_definitionsEspecialcompany_id O global (NULL)
faro.rule_evaluationsSicompany_id
faro.tensionsSicompany_id + responsable
faro.actionsSicompany_id + responsable/approver
faro.evidenceSicompany_id + submitted/reviewed
faro.action_status_historySicompany_id
faro.score_snapshotsSicompany_id
faro.reportsSicompany_id + rol
faro.notificationsSicompany_id + user_id
11 · V022 · Vistas seguras

RLS filtra filas; las vistas filtran columnas

RLS no resuelve restriccion por columna. Para eso existen vistas seguras: v_sales_public sin margen para vendedores; v_sales_executive con margen solo si is_executive_role(); v_my_actions filtra acciones a las propias; v_tension_inbox arma la bandeja ejecutiva enriquecida. Tambien existe v_rls_context_debug para diagnosticar contexto.

▸ V022__secure_views_sensitive_data.sql · vistas clave
-- Vista publica de ventas SIN margen ni costo (rol comercial / branch).
CREATE OR REPLACE VIEW faro.v_sales_public AS
SELECT
  sale_id, company_id, branch_id, customer_id, product_id, employee_id,
  sale_date, document_type, document_number,
  quantity, gross_amount, discount_amount, net_amount,
  discount_pct, currency_code, confidence_score, created_at
FROM faro.fact_sales;

GRANT SELECT ON faro.v_sales_public TO faro_app;

-- Vista ejecutiva de ventas CON margen (filtra por rol).
CREATE OR REPLACE VIEW faro.v_sales_executive AS
SELECT
  sale_id, company_id, branch_id, customer_id, product_id, employee_id,
  sale_date, document_type, document_number,
  quantity, gross_amount, discount_amount, net_amount,
  cost_amount, margin_amount, margin_pct,
  discount_pct, currency_code, confidence_score, created_at
FROM faro.fact_sales
WHERE
  faro.is_executive_role()
  OR faro.current_user_has_permission('facts.read');

GRANT SELECT ON faro.v_sales_executive TO faro_app;

-- Vista de acciones propias.
CREATE OR REPLACE VIEW faro.v_my_actions AS
SELECT a.*
FROM faro.actions a
WHERE
  a.company_id = faro.current_company_id()
  AND (
    a.responsible_user_id = faro.current_user_id()
    OR a.approver_user_id = faro.current_user_id()
    OR faro.is_executive_role()
  );

GRANT SELECT ON faro.v_my_actions TO faro_app;

-- Bandeja de tensiones enriquecida con responsable, area y sucursal.
CREATE OR REPLACE VIEW faro.v_tension_inbox AS
SELECT
  t.tension_id, t.company_id, t.tension_code, t.title, t.description,
  t.severity, t.priority_score, t.confidence_score, t.status,
  t.detected_at, t.due_at, t.responsible_user_id,
  u.full_name AS responsible_name,
  t.area_id, a.name AS area_name,
  t.branch_id, b.name AS branch_name,
  t.score_impact
FROM faro.tensions t
LEFT JOIN faro.users u ON u.user_id = t.responsible_user_id
LEFT JOIN faro.areas a ON a.area_id = t.area_id
LEFT JOIN faro.branches b ON b.branch_id = t.branch_id
WHERE
  t.company_id = faro.current_company_id();

GRANT SELECT ON faro.v_tension_inbox TO faro_app;

-- Vista de diagnostico del contexto RLS activo.
CREATE OR REPLACE VIEW faro.v_rls_context_debug AS
SELECT
  faro.current_company_id() AS current_company_id,
  faro.current_user_id() AS current_user_id,
  faro.current_role_codes() AS current_role_codes,
  faro.is_company_admin() AS is_company_admin,
  faro.is_executive_role() AS is_executive_role,
  faro.is_service_role() AS is_service_role;

GRANT SELECT ON faro.v_rls_context_debug TO faro_app;
Auditoria complementaria · trigger generico de cambios
▸ audit.log_row_change() + triggers
CREATE OR REPLACE FUNCTION audit.log_row_change()
RETURNS trigger AS $$
DECLARE
  v_company_id uuid;
  v_actor_user_id uuid;
BEGIN
  v_company_id := faro.current_company_id();
  v_actor_user_id := faro.current_user_id();

  IF TG_OP = 'INSERT' THEN
    INSERT INTO audit.audit_log (
      company_id, actor_user_id, entity_name, entity_id,
      action, before_data, after_data
    )
    VALUES (
      v_company_id, v_actor_user_id, TG_TABLE_NAME,
      COALESCE((to_jsonb(NEW)->>'id')::uuid, NULL),
      'insert', NULL, to_jsonb(NEW)
    );
    RETURN NEW;
  ELSIF TG_OP = 'UPDATE' THEN
    INSERT INTO audit.audit_log (...) VALUES (..., 'update', to_jsonb(OLD), to_jsonb(NEW));
    RETURN NEW;
  ELSIF TG_OP = 'DELETE' THEN
    INSERT INTO audit.audit_log (...) VALUES (..., 'delete', to_jsonb(OLD), NULL);
    RETURN OLD;
  END IF;

  RETURN NULL;
END;
$$ LANGUAGE plpgsql;

-- Aplicar triggers a tablas criticas.
CREATE TRIGGER trg_audit_tensions
AFTER INSERT OR UPDATE OR DELETE ON faro.tensions
FOR EACH ROW EXECUTE FUNCTION audit.log_row_change();

CREATE TRIGGER trg_audit_actions
AFTER INSERT OR UPDATE OR DELETE ON faro.actions
FOR EACH ROW EXECUTE FUNCTION audit.log_row_change();

CREATE TRIGGER trg_audit_evidence
AFTER INSERT OR UPDATE OR DELETE ON faro.evidence
FOR EACH ROW EXECUTE FUNCTION audit.log_row_change();

CREATE TRIGGER trg_audit_reports
AFTER INSERT OR UPDATE OR DELETE ON faro.reports
FOR EACH ROW EXECUTE FUNCTION audit.log_row_change();

CREATE TRIGGER trg_audit_users
AFTER INSERT OR UPDATE OR DELETE ON faro.users
FOR EACH ROW EXECUTE FUNCTION audit.log_row_change();
12 · V023 · Tests con dos tenants

Sin test negativo no hay aislamiento; solo esperanza

El test crea dos empresas (TENANT_A, TENANT_B), un usuario por empresa, una fuente por empresa y un KPI por empresa. Luego corre cuatro escenarios: A solo ve A, B solo ve B, sin contexto no se ven filas, intento de INSERT cruzado falla con ERROR: new row violates row-level security policy.

Seed de prueba (V023)

▸ V023__rls_test_two_tenants.sql · seed
-- ============================================================
-- FARO-SQL-002 · V023__rls_test_two_tenants.sql
-- Two tenant isolation test (UUIDs fijos repetibles).
-- ============================================================

INSERT INTO faro.companies (company_id, company_code, legal_name, display_name, status)
VALUES
  ('00000000-0000-0000-0000-000000000001', 'TENANT_A', 'Tenant A SA', 'Tenant A', 'pilot'),
  ('00000000-0000-0000-0000-000000000002', 'TENANT_B', 'Tenant B SA', 'Tenant B', 'pilot')
ON CONFLICT (company_id) DO NOTHING;

INSERT INTO faro.users (user_id, company_id, email, full_name, status)
VALUES
  ('11111111-1111-1111-1111-111111111111', '00000000-0000-0000-0000-000000000001',
   'director.a@test.local', 'Director Tenant A', 'active'),
  ('22222222-2222-2222-2222-222222222222', '00000000-0000-0000-0000-000000000002',
   'director.b@test.local', 'Director Tenant B', 'active')
ON CONFLICT (company_id, email) DO NOTHING;

INSERT INTO faro.data_sources (
  source_id, company_id, source_code, name,
  source_type, format_type, update_frequency, access_method, status
)
VALUES
  ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', '00000000-0000-0000-0000-000000000001',
   'SALES_A', 'Ventas Tenant A', 'csv', 'csv', 'weekly', 'manual_upload', 'active'),
  ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', '00000000-0000-0000-0000-000000000002',
   'SALES_B', 'Ventas Tenant B', 'csv', 'csv', 'weekly', 'manual_upload', 'active')
ON CONFLICT (company_id, source_code) DO NOTHING;

INSERT INTO faro.kpi_snapshots (
  company_id, kpi_code, period_start, period_end,
  dimension_type, dimension_id, value, status, confidence_score
)
VALUES
  ('00000000-0000-0000-0000-000000000001', 'KPI-SAL-001',
   '2026-05-01', '2026-05-31', 'company', NULL, 1000000, 'ok', 90),
  ('00000000-0000-0000-0000-000000000002', 'KPI-SAL-001',
   '2026-05-01', '2026-05-31', 'company', NULL, 2000000, 'ok', 90)
ON CONFLICT (company_id, kpi_code, period_start, period_end, dimension_type, dimension_id)
DO NOTHING;

Test 1 · Tenant A debe ver solo A

▸ V023 · test como Tenant A
BEGIN;

SELECT set_config('app.company_id', '00000000-0000-0000-0000-000000000001', true);
SELECT set_config('app.user_id', '11111111-1111-1111-1111-111111111111', true);
SELECT set_config('app.role_codes', 'director', true);

-- Debe ver solo Tenant A.
SELECT company_code FROM faro.companies;

-- Debe devolver solo SALES_A.
SELECT source_code, name FROM faro.data_sources ORDER BY source_code;

-- Debe devolver value = 1000000.
SELECT kpi_code, value FROM faro.kpi_snapshots WHERE kpi_code = 'KPI-SAL-001';

COMMIT;

Test 2 · Tenant B debe ver solo B

▸ V023 · test como Tenant B
BEGIN;

SELECT set_config('app.company_id', '00000000-0000-0000-0000-000000000002', true);
SELECT set_config('app.user_id', '22222222-2222-2222-2222-222222222222', true);
SELECT set_config('app.role_codes', 'director', true);

-- Debe ver solo Tenant B.
SELECT company_code FROM faro.companies;

-- Debe devolver solo SALES_B.
SELECT source_code, name FROM faro.data_sources ORDER BY source_code;

-- Debe devolver value = 2000000.
SELECT kpi_code, value FROM faro.kpi_snapshots WHERE kpi_code = 'KPI-SAL-001';

COMMIT;

Test 3 · Sin contexto, cero filas

▸ V023 · test sin contexto seteado
BEGIN;

RESET app.company_id;
RESET app.user_id;
RESET app.role_codes;

-- Debe devolver 0 filas.
SELECT * FROM faro.data_sources;

-- Debe devolver 0 filas.
SELECT * FROM faro.kpi_snapshots;

COMMIT;

Test 4 · Escritura cruzada prohibida

▸ V023 · INSERT con company_id ajeno · debe fallar
BEGIN;

SELECT set_config('app.company_id', '00000000-0000-0000-0000-000000000001', true);
SELECT set_config('app.user_id', '11111111-1111-1111-1111-111111111111', true);
SELECT set_config('app.role_codes', 'company_admin', true);

-- Debe fallar porque intenta insertar company_id de Tenant B
-- mientras el contexto es Tenant A.
INSERT INTO faro.data_sources (
  company_id, source_code, name,
  source_type, format_type, update_frequency, access_method, status
)
VALUES (
  '00000000-0000-0000-0000-000000000002',
  'ILLEGAL_SOURCE',
  'Fuente ilegal cruzada',
  'csv', 'csv', 'weekly', 'manual_upload', 'active'
);

-- Resultado esperado:
-- ERROR: new row violates row-level security policy

ROLLBACK;

Vista de diagnostico durante test

▸ uso de v_rls_context_debug + auditoria de policies activas
-- Ver contexto actual.
SELECT * FROM faro.v_rls_context_debug;

-- Auditar tablas con RLS activo.
SELECT schemaname, tablename, rowsecurity
FROM pg_tables
WHERE schemaname = 'faro'
ORDER BY tablename;

-- Listar policies activas en el schema faro.
SELECT schemaname, tablename, policyname, permissive, roles, cmd, qual, with_check
FROM pg_policies
WHERE schemaname = 'faro'
ORDER BY tablename, policyname;

Criterios de aceptacion tecnica

  • RLS activo en todas las tablas operativas (33 tablas).
  • current_company_id() funciona contra app.company_id.
  • current_user_id() funciona contra app.user_id.
  • current_role_codes() devuelve array desde app.role_codes.
  • Usuario sin contexto no ve datos (test 3 pasa).
  • Tenant A no ve Tenant B (test 1 pasa).
  • Tenant B no ve Tenant A (test 2 pasa).
  • No se permite insertar con company_id ajeno (test 4 falla esperado).
  • RAW queda restringido a service/admin/permiso.
  • Staging queda restringido a service/admin/permiso.
  • Facts respetan scope funcional (commercial, finanzas, stock).
  • Tensiones respetan empresa y responsable.
  • Acciones respetan empresa, responsable y approver.
  • Notifications respetan empresa + user_id.
  • Reports respetan empresa y rol.
  • Vistas sensibles separan margen.
  • Auditoria registra cambios en tablas criticas.

Riesgos tecnicos a vigilar

Riesgo Severidad Mitigacion
Backend no setea contextoAltaMiddleware obligatorio + test de smoke por endpoint
Pool reutiliza contexto anteriorCriticaTransaccion por request + reset al liberar conexion
Superuser bypass RLSAltaApp nunca usa superuser; runtime es faro_app
Owner bypass RLSMedia/AltaFORCE ROW LEVEL SECURITY en tablas criticas
Policies demasiado permisivasAltaTests negativos + review de policies por PR
Views exponen columnas sensiblesAltaRevisar grants de cada view; separar publica/exec
Funciones SECURITY DEFINER mal usadasAltaEvitar salvo necesidad clara; documentar excepciones
Migraciones corren con rol equivocadoMediaSeparar faro_migration de faro_app
Reports mezclan empresasCriticaToda generacion bajo contexto seteado o procedimiento validado
IA recibe datos sin filtrarCriticaIA solo consume queries ya autorizadas por RLS
13 · Cross-references

Donde se cruza esta capa con el resto del pack

La seguridad RLS no vive sola. Comparte tablas con el modelo SQL maestro, complementa la matriz RACI con permisos tecnicos, y habilita los proximos documentos de gobierno, motor evaluador y bandeja UI.

14 · Migraciones V014-V023

Diez archivos SQL ejecutables con dependencias claras

Toda la capa de seguridad multitenant cabe en diez migraciones secuenciales. Numeradas a partir de V014 para continuar despues de FARO-SQL-001 (V001-V013). Cada una es idempotente, ejecutable con Flyway o supabase-cli, y se valida con el bloque V023.

V014

security_context_functions.sql

Crea 8 funciones helper: current_company_id(), current_user_id(), current_role_codes(), has_role(), has_any_role(), is_service_role(), is_company_admin(), is_executive_role().

depende: FARO-SQL-001
V015

application_db_roles_and_grants.sql

Crea 4 roles PostgreSQL: faro_app, faro_migration, faro_readonly, faro_ingestion. Aplica GRANTs minimos por schema y default privileges.

depende: V014
V016

seed_roles_permissions.sql

Seedea 13 roles funcionales + 28 permisos base. Idempotente via ON CONFLICT. Crea current_user_has_permission() helper.

depende: V014, V015
V017

enable_rls_core_tables.sql

Activa ENABLE ROW LEVEL SECURITY en 33 tablas operativas. Aplica FORCE ROW LEVEL SECURITY en 8 tablas criticas (companies, users, fact_sales, tensions, actions, evidence, score_snapshots, reports).

depende: V014, V015, V016
V018

rls_policies_org_sources_raw.sql

Policies para companies, branches, areas, users, user_roles, raci_assignments, data_sources, source_fields, ingestion_batches, raw_records.

depende: V017
V019

rls_policies_master_staging_facts.sql

Policies para customers, products, suppliers, employees, staging_sales/stock/receivables/purchases, fact_sales/stock/receivables/purchases.

depende: V017
V020

rls_policies_kpis_rules_tensions_actions.sql

Policies para kpi_snapshots, signal_log, rule_definitions (con global NULL), rule_evaluations, tensions, actions, evidence, action_status_history.

depende: V017
V021

rls_policies_score_reports_notifications.sql

Policies para score_snapshots, reports y notifications (con filtro user_id propio).

depende: V017
V022

secure_views_sensitive_data.sql

Crea vistas: v_sales_public (sin margen), v_sales_executive (con margen), v_my_actions, v_tension_inbox, v_rls_context_debug.

depende: V018, V019, V020, V021
V023

rls_test_two_tenants.sql

Seed de prueba con TENANT_A + TENANT_B + un usuario y un KPI por empresa. Incluye 4 tests negativos para validar aislamiento. Bloqueante de QA.

depende: V018, V019, V020, V021, V022

Numeracion. Las migraciones V014-V023 continuan despues de FARO-SQL-001 (V001-V013 · DDL base) y antes de FARO-SQL-003 (seeds Empresa Demo · V024+). Si se usa Flyway, la convencion de naming es V{NN}__{descripcion}.sql. Si se usa supabase-cli, el timestamp se genera automaticamente pero el orden semantico se preserva.

Criterio de rechazo. Esta capa se rechaza si: usuario sin contexto ve datos · Tenant A ve datos Tenant B · usuario puede insertar company_id ajeno · RAW queda visible para usuarios comunes · reports pueden mezclar empresas · backend usa superuser · conexion pooled arrastra contexto · no hay tests negativos · IA accede directo a datos sin contexto. Cualquiera de estos es bloqueante de piloto.