Implement row-level security (RLS) context management for database sessions. Refactor invoice processing and reverting tasks to utilize scoped database sessions with RLS context. Update middleware to extract and set company ID from requests. Enhance task dispatching to propagate RLS context via Celery headers. Update architecture documentation to reflect RLS implementation details.

This commit is contained in:
2026-04-24 18:01:46 -05:00
parent 7acf77994f
commit 73a6d92863
13 changed files with 1052 additions and 306 deletions

View File

@@ -0,0 +1,302 @@
"""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")