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:
@@ -395,6 +395,75 @@ elif tenant.type == "DEDICATED":
|
||||
- **Row-level security** en BD compartida
|
||||
- **BD dedicada** para mayor aislamiento (enterprise)
|
||||
|
||||
### Row-Level Security (RLS) en BD compartida
|
||||
|
||||
> Convención alineada al skill `aduanasoft-dev-standards` (sección 10).
|
||||
> La capa API sigue siendo responsable del control fino (roles/permisos
|
||||
> con Keycloak + `PermissionService`); RLS añade **defensa en profundidad**
|
||||
> a nivel de BD para que un bug en un `WHERE` no permita salirse del tenant.
|
||||
|
||||
#### Variables de sesión (`SET LOCAL`)
|
||||
|
||||
| GUC | Origen | Comportamiento RLS |
|
||||
|-----|--------|--------------------|
|
||||
| `app.tenant_id` | JWT (`TenantMiddleware`) → `request.state.tenant_id` | Obligatoria. Si está vacía, `app.current_tenant_id()` retorna `NULL` y las políticas devuelven `0` filas (fail-closed). |
|
||||
| `app.company_id` | Header `X-Company-Id` o cookie `active_company_id` | Opcional. Si está vacía, el tenant ve **todas sus compañías** (útil para selectores de compañía y bootstrap). |
|
||||
|
||||
Ambas se fijan con `SET LOCAL` al inicio de cada transacción —
|
||||
**nunca** con `SET` global, para no contaminar conexiones del pool.
|
||||
|
||||
Helpers SQL definidos por la migración `d1a2b3c4e5f6_enable_rls_tenant_company`:
|
||||
|
||||
```sql
|
||||
CREATE FUNCTION app.current_tenant_id() RETURNS INTEGER LANGUAGE sql STABLE AS
|
||||
$$ SELECT NULLIF(current_setting('app.tenant_id', true), '')::INTEGER $$;
|
||||
CREATE FUNCTION app.current_company_id() RETURNS INTEGER LANGUAGE sql STABLE AS
|
||||
$$ SELECT NULLIF(current_setting('app.company_id', true), '')::INTEGER $$;
|
||||
```
|
||||
|
||||
#### Tipos de política
|
||||
|
||||
1. **Solo `tenant_id`** (p.ej. `a76.company`, `core.licenses`):
|
||||
`tenant_id = app.current_tenant_id()`.
|
||||
2. **`tenant_id` + `company_id`** (`TenantScopedMixin`, mayoría de tablas
|
||||
`a24/`a76/`core`): además exige `company_id = app.current_company_id()`
|
||||
cuando esa GUC está fijada.
|
||||
3. **Solo `company_id`** (algunas tablas `a76.company_*`): valida el
|
||||
`tenant_id` indirectamente vía `EXISTS` contra `a76.company`.
|
||||
|
||||
Todas las tablas usan `FORCE ROW LEVEL SECURITY` para que la política
|
||||
aplique también al owner. Las únicas tablas core **excluidas** son
|
||||
`core.tenants` y `core.user_tenants` — necesarias para el bootstrap del
|
||||
selector de tenant antes de tener contexto fijado.
|
||||
|
||||
#### Propagación del contexto
|
||||
|
||||
| Camino | Cómo se fija el contexto |
|
||||
|--------|--------------------------|
|
||||
| HTTP request | `TenantMiddleware` rellena `request.state.tenant_id`/`company_id`; `get_core_db` / `get_async_core_db` leen esos valores y los guardan en `Session.info`. Un listener `after_begin` ejecuta `SET LOCAL` por transacción. |
|
||||
| `LicenseValidationMiddleware` | Usa `scoped_core_db(tenant_id=...)` para que la consulta de licencia entre con contexto RLS válido. |
|
||||
| Tareas Celery | `track_and_dispatch` inyecta `rls_tenant_id` / `rls_company_id` en los headers del task; los signals `task_prerun`/`task_postrun` los copian a `ContextVar`s del worker, que el listener `after_begin` consume como fallback. Tareas críticas (imports/exports de invoices, expediente) abren la sesión con `scoped_core_db(tenant_id=..., company_id=...)`. |
|
||||
| Tests | Las suites de pytest pueden usar `scoped_core_db(...)` o emular el flujo con `set_config('app.tenant_id', ...)` antes del query. Hay un set de tests en `backend/tests/integration/test_rls_tenant_company.py` que valida aislamiento A vs B usando un rol sin `BYPASSRLS`. |
|
||||
|
||||
#### Reparto de responsabilidades
|
||||
|
||||
| Capa | Decide |
|
||||
|------|--------|
|
||||
| **API (FastAPI + Keycloak + `PermissionService`)** | Roles, permisos por compañía, accesos a recursos concretos (`validate_access_to_resource`), reglas de negocio. |
|
||||
| **RLS (PostgreSQL)** | Límite estructural duro: `tenant_id` y `company_id`. **No** modela roles/permisos para evitar duplicar lógica fina con la API. |
|
||||
|
||||
#### Operación / DevOps
|
||||
|
||||
- En **producción** la API debe conectar con un rol **sin** `BYPASSRLS`
|
||||
(`postgres` superusuario lo bypassea por diseño). El `docker-compose.yml`
|
||||
de desarrollo usa `postgres` deliberadamente para no romper migraciones;
|
||||
los tests crean un rol `anexo76_rls_test` para ejercitar las políticas.
|
||||
- Los jobs/ETL/migraciones que necesiten ver todos los tenants deben usar
|
||||
un rol técnico explícito con `BYPASSRLS` o fijar `app.tenant_id` por
|
||||
iteración — nunca asumir que la sesión global "ve todo".
|
||||
- La migración `d1a2b3c4e5f6_enable_rls_tenant_company` tiene `downgrade()`
|
||||
completo (drop policies + `DISABLE ROW LEVEL SECURITY`) para revertir.
|
||||
|
||||
### Validación de Licencias
|
||||
- Middleware verifica en cada request:
|
||||
- ✓ Licencia activa
|
||||
|
||||
Reference in New Issue
Block a user