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.
Capa minima de aislamiento multiempresa para FARO Connect: contexto de sesion, 13 roles funcionales, 6 scopes, 16 modulos, policies PostgreSQL RLS, vistas seguras, auditoria y tests con dos tenants. Cinturon y tirantes desde el primer dia.
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:
app.company_id, app.user_id, app.role_codes) antes de ejecutar queries.company_id = faro.current_company_id().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).
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.
RLS no reemplaza al backend; lo respalda. Si el backend olvida filtrar por company_id, PostgreSQL filtra igual. Cinturon y tirantes.
No hay version "single-tenant" del piloto. Todas las tablas operativas llevan company_id NOT NULL y RLS desde el dia uno.
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.
La app se conecta como faro_app (rol controlado, NOINHERIT). Solo migraciones usan faro_migration. Superuser no aparece en runtime.
RLS se aplica tambien al owner de la tabla. Evita el bypass clasico cuando el owner cae en una migracion mal corrida.
Para MVP no se permite DELETE fisico desde aplicacion. Patron: status = archived, deleted_at = now(), auditoria. Trazabilidad protegida.
faro.roles guarda roles funcionales (director, area_manager). PostgreSQL solo conoce faro_app. La separacion permite mover negocio sin tocar la DB.
RLS filtra filas. Lo que filtra columnas son vistas: v_sales_public sin margen para vendedores, v_sales_executive con margen para direccion.
La IA explica y resume datos ya autorizados; nunca consulta tablas crudas sin RLS. La IA explica, no rompe gobierno de datos.
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.
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.
set_config('app.company_id', ...), app.user_id, app.role_codes.company_id = faro.current_company_id(). Si el contexto no esta seteado, no hay filas.faro.is_executive_role(), faro.current_user_has_permission('tensions.manage'), etc.tensions, actions, evidence, reports, users) registran INSERT/UPDATE/DELETE con actor y company_id.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.
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).
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.
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'.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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().
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.
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.
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.
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.
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.
-- ============================================================ -- 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';
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.
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.
-- ============================================================ -- 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.
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.
-- ============================================================ -- 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;
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;
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;
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).
-- 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;
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.
-- 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() ) );
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.
-- 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).
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).
-- 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') ) );
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.
-- 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() ) );
| Tabla | RLS | Politica base |
|---|---|---|
faro.companies | Si | company_id (faro_owner bypass) |
faro.branches | Si | company_id |
faro.areas | Si | company_id |
faro.users | Si | company_id |
faro.user_roles | Si | company_id |
faro.raci_assignments | Si | company_id |
faro.data_sources | Si | company_id + admin/service |
faro.source_fields | Si | company_id + admin/service |
faro.ingestion_batches | Si | company_id + rol tecnico |
faro.raw_records | Si | company_id + raw.read/write |
faro.customers | Si | company_id |
faro.products | Si | company_id |
faro.suppliers | Si | company_id |
faro.employees | Si | company_id + rol/propio |
faro.staging_sales | Si | company_id + admin/service |
faro.staging_stock | Si | company_id + admin/service |
faro.staging_receivables | Si | company_id + admin/service |
faro.staging_purchases | Si | company_id + admin/service |
faro.fact_sales | Si | company_id + scope comercial |
faro.fact_stock_snapshots | Si | company_id + scope stock |
faro.fact_receivables | Si | company_id + scope finanzas |
faro.fact_purchases | Si | company_id + scope compras |
faro.kpi_snapshots | Si | company_id |
faro.signal_log | Si | company_id |
faro.rule_definitions | Especial | company_id O global (NULL) |
faro.rule_evaluations | Si | company_id |
faro.tensions | Si | company_id + responsable |
faro.actions | Si | company_id + responsable/approver |
faro.evidence | Si | company_id + submitted/reviewed |
faro.action_status_history | Si | company_id |
faro.score_snapshots | Si | company_id |
faro.reports | Si | company_id + rol |
faro.notifications | Si | company_id + user_id |
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.
-- 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;
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();
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.
-- ============================================================ -- 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;
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;
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;
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;
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;
-- 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;
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.company_id ajeno (test 4 falla esperado).| Riesgo | Severidad | Mitigacion |
|---|---|---|
| Backend no setea contexto | Alta | Middleware obligatorio + test de smoke por endpoint |
| Pool reutiliza contexto anterior | Critica | Transaccion por request + reset al liberar conexion |
| Superuser bypass RLS | Alta | App nunca usa superuser; runtime es faro_app |
| Owner bypass RLS | Media/Alta | FORCE ROW LEVEL SECURITY en tablas criticas |
| Policies demasiado permisivas | Alta | Tests negativos + review de policies por PR |
| Views exponen columnas sensibles | Alta | Revisar grants de cada view; separar publica/exec |
| Funciones SECURITY DEFINER mal usadas | Alta | Evitar salvo necesidad clara; documentar excepciones |
| Migraciones corren con rol equivocado | Media | Separar faro_migration de faro_app |
| Reports mezclan empresas | Critica | Toda generacion bajo contexto seteado o procedimiento validado |
| IA recibe datos sin filtrar | Critica | IA solo consume queries ya autorizadas por RLS |
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.
DDL completo del sistema FARO Connect. Esta capa RLS opera sobre las tablas creadas en V001-V013 (FARO-SQL-001).
RACI define responsabilidad por proceso (quien aprueba el cierre de TNS-002). Esta capa define permiso por modulo/tabla. Coexisten sin solaparse.
Gobierno, seguridad organizacional, auditoria legal y permisos a nivel proceso. Complementa la capa tecnica con politica de empresa.
Motor que evalua reglas YAML y dispara tensiones. Corre bajo integration_service, inserta en faro.tensions respetando RLS.
30 tensiones canonicas TNS-001..TNS-030. Cada tension referencia default_owner_role y approver_role que aqui se materializan como permisos RLS.
Contrato de datos del MVP. RAW, staging y facts referenciados aqui estan definidos en el contrato y heredan RLS.
Backlog del piloto MVP. Las migraciones V014-V023 estan listadas como tareas P1 criticas para que el piloto pase QA de seguridad.
528 campos del diccionario tecnico. Aqui se verifica que cada campo con sensibilidad PII vive en tabla con RLS activo.
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.
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().
Crea 4 roles PostgreSQL: faro_app, faro_migration, faro_readonly, faro_ingestion. Aplica GRANTs minimos por schema y default privileges.
Seedea 13 roles funcionales + 28 permisos base. Idempotente via ON CONFLICT. Crea current_user_has_permission() helper.
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).
Policies para companies, branches, areas, users, user_roles, raci_assignments, data_sources, source_fields, ingestion_batches, raw_records.
depende: V017Policies para customers, products, suppliers, employees, staging_sales/stock/receivables/purchases, fact_sales/stock/receivables/purchases.
depende: V017Policies para kpi_snapshots, signal_log, rule_definitions (con global NULL), rule_evaluations, tensions, actions, evidence, action_status_history.
depende: V017Policies para score_snapshots, reports y notifications (con filtro user_id propio).
depende: V017Crea vistas: v_sales_public (sin margen), v_sales_executive (con margen), v_my_actions, v_tension_inbox, v_rls_context_debug.
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, V022Numeracion. 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.
Esta capa es la base para FARO-SQL-003 (seeds Empresa Demo), FARO-ENG-003 (motor evaluador bajo contexto), FARO-GOV-001 (gobierno organizacional) y FARO-UI-001 (bandeja consumiendo v_tension_inbox). Volver al hub para ver el resto del pack o seguir con catalogo de tensiones.