Files
plantillas-proyectos/backend/alembic/versions/d1a2b3c4e5f6_enable_rls_tenant_company.py

303 lines
11 KiB
Python

"""enable_rls_tenant_company
Habilita Row-Level Security en las tablas multi-tenant conforme al skill
`aduanasoft-dev-standards` (sección 10). Las políticas dependen de dos
GUCs que la aplicación establece por transacción con `SET LOCAL`:
- ``app.tenant_id`` (ID del tenant actual, obligatorio para aislamiento)
- ``app.company_id`` (ID de la compañía activa; opcional — si no está fijado
la política permite todas las compañías del tenant, útil para vistas de
selector de compañía / bootstrap de sesión)
Las funciones SQL viven en el esquema ``app`` y retornan ``NULL`` cuando la
GUC correspondiente está vacía, lo que hace que las comparaciones
``col = app.current_xxx_id()`` devuelvan 0 filas sin contexto (fail-closed
para ``tenant_id``).
Las tablas ``core.tenants`` y ``core.user_tenants`` NO quedan bajo RLS: son
necesarias para el bootstrap de la sesión (obtener tenant del JWT y listar
los tenants del usuario en el selector).
La migración instala ``FORCE ROW LEVEL SECURITY`` para que las políticas
apliquen también al owner — los superusuarios (p. ej. ``postgres`` en dev)
siguen haciendo bypass por diseño de PostgreSQL; en producción la API debe
conectarse con un rol sin BYPASSRLS.
Revision ID: d1a2b3c4e5f6
Revises: c8d9e0f1a2b3
Create Date: 2026-04-24 17:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
revision: str = "d1a2b3c4e5f6"
down_revision: Union[str, Sequence[str], None] = "c8d9e0f1a2b3"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
TABLES_TENANT_ONLY: list[tuple[str, str]] = [
("a76", "company"),
("core", "license_usage"),
("core", "licenses"),
]
TABLES_TENANT_AND_COMPANY: list[tuple[str, str]] = [
("a24", "balance_movement"),
("a24", "discharge_detail"),
("a24", "discharge_header"),
("a24", "discharge_scrap"),
("a24", "fa_classes"),
("a24", "fa_item_lines"),
("a24", "fa_partes"),
("a24", "inv_aphis_characteristic"),
("a24", "inv_aphis_containers"),
("a24", "inv_aphis_entities"),
("a24", "inv_aphis_general"),
("a24", "inv_aphis_lpcos"),
("a24", "inv_aphis_routing"),
("a24", "inv_aphis_stype_pitems"),
("a24", "inv_bom"),
("a24", "inv_classes"),
("a24", "inv_parte_paises"),
("a24", "inv_partes"),
("a76", "app_settings"),
("a76", "audit_logs"),
("a76", "canadian_tariff_fractions"),
("a76", "classes"),
("a76", "classification_concepts"),
("a76", "clients_and_providers"),
("a76", "clients_and_providers_address"),
("a76", "clients_and_providers_programs"),
("a76", "concept_manifestations"),
("a76", "concepts"),
("a76", "country_rule_oct"),
("a76", "ctm_receipts"),
("a76", "customs_broker_concepts"),
("a76", "customs_brokers"),
("a76", "customs_brokers_personnel"),
("a76", "customs_brokers_vu"),
("a76", "depreciation_catalog"),
("a76", "document_types_digitization"),
("a76", "doda"),
("a76", "doda_american_pedimentos"),
("a76", "doda_container_seals"),
("a76", "doda_containers"),
("a76", "doda_pedimentos"),
("a76", "driver"),
("a76", "electronic_notices"),
("a76", "equivalencies"),
("a76", "equivalency_items"),
("a76", "error_catalogs"),
("a76", "error_classifications"),
("a76", "exchange_rate"),
("a76", "fa_location_ext"),
("a76", "fda_affirmation_codes"),
("a76", "fda_catalog"),
("a76", "fda_constituent_elements"),
("a76", "fda_lot_production"),
("a76", "fda_specifications"),
("a76", "fraction_rule_octave"),
("a76", "historical_tariff_fractions"),
("a76", "identifier_details"),
("a76", "identifiers"),
("a76", "inpc"),
("a76", "invoice_collections"),
("a76", "invoice_compliance_mx"),
("a76", "invoice_financials"),
("a76", "invoice_header"),
("a76", "invoice_logistics"),
("a76", "invoice_sales_details"),
("a76", "invoice_settings"),
("a76", "item_line_series"),
("a76", "item_lines"),
("a76", "item_presets"),
("a76", "legends"),
("a76", "location"),
("a76", "manifest_anexos"),
("a76", "manifest_drivers"),
("a76", "manifests"),
("a76", "multi_currency_types"),
("a76", "octave_balance"),
("a76", "packages"),
("a76", "packing_lists"),
("a76", "parts"),
("a76", "pedimento_config_additional"),
("a76", "pedimento_config_calculations"),
("a76", "pedimento_config_parameters"),
("a76", "pedimento_config_surcharges"),
("a76", "pedimento_config_update_rectification"),
("a76", "pedimento_config_updates"),
("a76", "pedimento_containers"),
("a76", "pedimento_contributions"),
("a76", "pedimento_customs_offices"),
("a76", "pedimento_dates"),
("a76", "pedimento_decrementables"),
("a76", "pedimento_guides"),
("a76", "pedimento_incrementables"),
("a76", "pedimento_indexes"),
("a76", "pedimento_packages"),
("a76", "pedimento_payments"),
("a76", "pedimento_rectification_destination"),
("a76", "pedimento_rectification_origin"),
("a76", "pedimento_seals"),
("a76", "pedimento_transport_carriers"),
("a76", "pedimento_transport_means"),
("a76", "pedimento_validation"),
("a76", "pedimentos"),
("a76", "permission_rule_oct"),
("a76", "permission_rule_octave"),
("a76", "ports"),
("a76", "prevalidators"),
("a76", "previous_fractions"),
("a76", "seal"),
("a76", "sectors"),
("a76", "signatures"),
("a76", "subassembly_entries"),
("a76", "trailer"),
("a76", "transporter"),
("a76", "unit_conversions"),
("a76", "units_of_measure"),
("a76", "units_of_measure_general"),
("a76", "us_tariff_fractions"),
("a76", "value_manifestations"),
("a76", "vehicle"),
("core", "company_roles"),
("core", "role_permissions"),
("core", "user_company_permissions"),
("core", "user_company_roles"),
("public", "warning_fractions"),
]
TABLES_COMPANY_ONLY: list[tuple[str, str]] = [
("a24", "inv_aphis_catalog"),
("a76", "company_address"),
("a76", "company_certification"),
("a76", "company_cfdi"),
("a76", "company_digital_certificate"),
("a76", "company_electronic_agent"),
("a76", "company_prevalidator"),
]
POLICY_TENANT_ONLY = "tenant_isolation"
POLICY_TENANT_COMPANY = "tenant_company_isolation"
POLICY_COMPANY_ONLY = "company_isolation"
def upgrade() -> None:
"""Habilita RLS con políticas de aislamiento por tenant_id / company_id."""
op.execute("CREATE SCHEMA IF NOT EXISTS app")
op.execute(
"""
CREATE OR REPLACE FUNCTION app.current_tenant_id() RETURNS INTEGER
LANGUAGE sql STABLE AS $$
SELECT NULLIF(current_setting('app.tenant_id', true), '')::INTEGER
$$
"""
)
op.execute(
"""
CREATE OR REPLACE FUNCTION app.current_company_id() RETURNS INTEGER
LANGUAGE sql STABLE AS $$
SELECT NULLIF(current_setting('app.company_id', true), '')::INTEGER
$$
"""
)
for schema, table in TABLES_TENANT_ONLY:
op.execute(f'ALTER TABLE "{schema}"."{table}" ENABLE ROW LEVEL SECURITY')
op.execute(f'ALTER TABLE "{schema}"."{table}" FORCE ROW LEVEL SECURITY')
op.execute(
f"""
CREATE POLICY {POLICY_TENANT_ONLY} ON "{schema}"."{table}"
USING (tenant_id = app.current_tenant_id())
WITH CHECK (tenant_id = app.current_tenant_id())
"""
)
for schema, table in TABLES_TENANT_AND_COMPANY:
op.execute(f'ALTER TABLE "{schema}"."{table}" ENABLE ROW LEVEL SECURITY')
op.execute(f'ALTER TABLE "{schema}"."{table}" FORCE ROW LEVEL SECURITY')
op.execute(
f"""
CREATE POLICY {POLICY_TENANT_COMPANY} ON "{schema}"."{table}"
USING (
tenant_id = app.current_tenant_id()
AND (
app.current_company_id() IS NULL
OR company_id = app.current_company_id()
)
)
WITH CHECK (
tenant_id = app.current_tenant_id()
AND (
app.current_company_id() IS NULL
OR company_id = app.current_company_id()
)
)
"""
)
for schema, table in TABLES_COMPANY_ONLY:
op.execute(f'ALTER TABLE "{schema}"."{table}" ENABLE ROW LEVEL SECURITY')
op.execute(f'ALTER TABLE "{schema}"."{table}" FORCE ROW LEVEL SECURITY')
op.execute(
f"""
CREATE POLICY {POLICY_COMPANY_ONLY} ON "{schema}"."{table}"
USING (
EXISTS (
SELECT 1 FROM a76.company c
WHERE c.id = "{schema}"."{table}".company_id
AND c.tenant_id = app.current_tenant_id()
)
AND (
app.current_company_id() IS NULL
OR company_id = app.current_company_id()
)
)
WITH CHECK (
EXISTS (
SELECT 1 FROM a76.company c
WHERE c.id = "{schema}"."{table}".company_id
AND c.tenant_id = app.current_tenant_id()
)
AND (
app.current_company_id() IS NULL
OR company_id = app.current_company_id()
)
)
"""
)
def downgrade() -> None:
"""Revierte: drop policies, deshabilita RLS y elimina helpers."""
for schema, table in TABLES_COMPANY_ONLY:
op.execute(
f'DROP POLICY IF EXISTS {POLICY_COMPANY_ONLY} ON "{schema}"."{table}"'
)
op.execute(f'ALTER TABLE "{schema}"."{table}" NO FORCE ROW LEVEL SECURITY')
op.execute(f'ALTER TABLE "{schema}"."{table}" DISABLE ROW LEVEL SECURITY')
for schema, table in TABLES_TENANT_AND_COMPANY:
op.execute(
f'DROP POLICY IF EXISTS {POLICY_TENANT_COMPANY} ON "{schema}"."{table}"'
)
op.execute(f'ALTER TABLE "{schema}"."{table}" NO FORCE ROW LEVEL SECURITY')
op.execute(f'ALTER TABLE "{schema}"."{table}" DISABLE ROW LEVEL SECURITY')
for schema, table in TABLES_TENANT_ONLY:
op.execute(
f'DROP POLICY IF EXISTS {POLICY_TENANT_ONLY} ON "{schema}"."{table}"'
)
op.execute(f'ALTER TABLE "{schema}"."{table}" NO FORCE ROW LEVEL SECURITY')
op.execute(f'ALTER TABLE "{schema}"."{table}" DISABLE ROW LEVEL SECURITY')
op.execute("DROP FUNCTION IF EXISTS app.current_company_id()")
op.execute("DROP FUNCTION IF EXISTS app.current_tenant_id()")
op.execute("DROP SCHEMA IF EXISTS app")