db: migrar infraestructura de BD a Postgres (fase 0-1)

- Fase 0: scripts de aprovisionamiento (db/provision/) para roles, dos
  bases de datos separadas (panels_platform / panels_product con
  esquemas iam+core) y ACLs de Redis por modulo, con verificacion
  automatizada de aislamiento (verify-isolation.sh) y setup local
  reproducible (dev-local.sh).
- Fase 1: changelogs de Liquibase reescritos para Postgres
  (db/platform, db/iam, db/core reemplazan db/app + los changesets
  SQLite de platform). Baseline como estado final (no replay literal),
  tipos traducidos (IDENTITY, TIMESTAMPTZ/DATE, NUMERIC, BOOLEAN,
  CITEXT), contexts dev vs. schema/catalogos, RLS por tenant_id como
  defensa en profundidad, uploaded_by/created_by como snapshot
  desnormalizado (sin FK hacia iam).
- Migraciones ya no corren en el arranque de la API: paso explicito de
  deploy via db/update.sh con credenciales _owner.

Verificado end-to-end contra Postgres 16 + Redis local.

Co-authored-by: alberto.martinez <alberto.martinez@mrdev.mx>
This commit is contained in:
Cursor Agent 2026-09-02 20:00:49 +00:00
parent 97c66282bf
commit b87b0205a8
No known key found for this signature in database
39 changed files with 1471 additions and 691 deletions

View file

@ -1,28 +1,73 @@
# Database migrations (Liquibase)
# Base de datos (Postgres + Liquibase)
Homologa el schema de cada ambiente con la misma cadena de changesets.
PANELS usa **Postgres** organizado como monolito modular: dos bases de datos
físicamente separadas en el mismo servidor/instancia por ambiente.
## Bases
| Base de datos | Esquema(s) | Changelog | Qué vive ahí |
|---|---|---|---|
| `panels_platform` | `public` | `db/platform/` | Control plane SaaS: `tenants`, `platform_users` (identidad de PANELS como operador), `smtp_settings`. Solo lo tocan las rutas `/v1/saas/*`. |
| `panels_product` | `iam` | `db/iam/` | Identidad/roles/permisos **de cada tenant** (ej. usuarios de ARCTEC). Aislado de `core` -- sin JOIN/FK cruzado. |
| `panels_product` | `core` | `db/core/` | Negocio: empresas, personal, obras, expedientes, presupuesto, nómina, gafetes. |
| Archivo | Changelog |
|---------|-----------|
| `data/app.db` | `db/app/` |
| `data/platform.db` | `db/platform/` |
No hay FK real entre `panels_platform` y `panels_product` (bases distintas),
ni entre los esquemas `iam` y `core` (aislamiento a propósito). `tenant_id`
es una referencia lógica validada en la capa de aplicación. Ver el plan de
migración para el detalle de la arquitectura y las reglas del monolito
modular.
## Cómo aplicar
## Aprovisionamiento (una vez por ambiente, antes de Liquibase)
Requisitos: Java 17+.
Ver [`db/provision/README.md`](provision/README.md): crea los 6 roles de
Postgres (`panels_{platform,iam,core}_{owner,app}`), las 2 bases de datos,
los esquemas `iam`/`core`, la extensión `citext`, y los usuarios ACL de
Redis. Incluye `dev-local.sh` para reproducir todo esto en una máquina de
desarrollo sin depender de Coolify, y `verify-isolation.sh` para confirmar
que el aislamiento realmente se cumple.
## Aplicar migraciones
Requiere Java 17+ y las credenciales del rol `_owner` de cada módulo (nunca
`_app`, que es solo runtime):
```bash
# descarga CLI + JDBC (una vez) y aplica changesets
./db/update.sh all
# o solo una
./db/update.sh app
# Local, después de correr db/provision/dev-local.sh:
set -a && source .env.dev-local && set +a
./db/update.sh all --context-filter=dev # dev: incluye datos de demo
./db/update.sh all # staging/producción: solo esquema + catálogos
# Un solo módulo
./db/update.sh core
./db/update.sh iam
./db/update.sh platform
```
También: `npm run db:migrate` o al arrancar la API (`runLiquibase()` en Deno).
`npm run db:migrate` corre `./db/update.sh all`. **Las migraciones ya NO
corren automáticamente al arrancar la API** -- son un paso explícito de
deploy (a diferencia del `runLiquibase()` que existía con SQLite).
Nuevos cambios de schema → nuevo changeset en `db/app/changesets/` o `db/platform/changesets/` y referenciarlo en el `changelog-master.xml` correspondiente. No añadir migraciones en `api/db.ts`.
Contexts de Liquibase:
- Sin `context`: changesets de esquema y catálogos requeridos (`risk_levels`,
`document_types`, etc.) -- corren en **todos** los ambientes.
- `context="dev"`: datos de demostración (tenant/empresas/proyecto ARCTEC) --
**nunca** en staging/producción. Siempre pasar `--context-filter`
explícito; no confiar en el default de Liquibase.
Las herramientas viven en `db/tools/` (gitignored); `bootstrap-tools.sh` las descarga.
## Nuevos cambios de schema
Un changeset nuevo en `db/{platform,iam,core}/changesets/`, referenciado en
el `changelog-master.xml` correspondiente. Reglas:
- Nunca editar un changeset ya aplicado en un ambiente compartido -- crear
uno nuevo.
- Usar `dbms:postgresql` en vez de `context` para lógica específica de motor.
- No añadir migraciones directamente en `api/*.ts`.
- Las herramientas (Liquibase + driver JDBC de Postgres) viven en
`db/tools/` (gitignored); `bootstrap-tools.sh` las descarga.
## Bootstrap del primer usuario administrador
El `password_hash` (PBKDF2) no lo puede generar un changeset SQL. El primer
`platform_admin` y el primer `tenant_admin` de un tenant nuevo se crean con
`scripts/bootstrap-admin.ts` (ver Fase 4 del plan de migración), no con
lógica de seed en el arranque de la API ni con datos hardcodeados en
Liquibase.

View file

@ -1,13 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
https://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
<include file="changesets/001-baseline.sql" relativeToChangelogFile="true"/>
<include file="changesets/002-tenant-id.sql" relativeToChangelogFile="true"/>
<include file="changesets/003-must-change-password.sql" relativeToChangelogFile="true"/>
<include file="changesets/004-document-catalog.sql" relativeToChangelogFile="true"/>
<include file="changesets/005-payroll-week.sql" relativeToChangelogFile="true"/>
</databaseChangeLog>

View file

@ -1,254 +0,0 @@
--liquibase formatted sql
--changeset panel:app-001-baseline endDelimiter:; splitStatements:true
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='users'
--comment: Baseline schema (skipped/MARK_RAN when legacy schema.sql already applied)
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
display_name TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS companies (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
parent_id INTEGER REFERENCES companies(id),
kind TEXT NOT NULL DEFAULT 'sub' CHECK (kind IN ('principal', 'sub')),
status TEXT NOT NULL DEFAULT 'activo' CHECK (status IN ('activo', 'inactivo')),
registro_patronal TEXT NOT NULL DEFAULT '',
razon_social TEXT NOT NULL DEFAULT '',
nombre_comercial TEXT NOT NULL DEFAULT '',
rfc TEXT NOT NULL DEFAULT '',
regimen_fiscal TEXT NOT NULL DEFAULT '',
clase_riesgo TEXT NOT NULL DEFAULT '',
domicilio_fiscal TEXT NOT NULL DEFAULT '',
codigo_postal TEXT NOT NULL DEFAULT '',
ciudad TEXT NOT NULL DEFAULT '',
estado TEXT NOT NULL DEFAULT '',
telefono TEXT NOT NULL DEFAULT '',
email TEXT NOT NULL DEFAULT '',
representante_legal TEXT NOT NULL DEFAULT '',
giro TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS risk_levels (
code TEXT PRIMARY KEY,
label TEXT NOT NULL,
color TEXT NOT NULL,
text_color TEXT NOT NULL DEFAULT '#FFFFFF'
);
CREATE TABLE IF NOT EXISTS badge_themes (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
layout TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS projects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
address TEXT NOT NULL DEFAULT '',
stage TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'activo' CHECK (status IN ('activo', 'pausado', 'concluido', 'cancelado')),
theme_id TEXT NOT NULL REFERENCES badge_themes(id),
logo_left_path TEXT,
logo_right_path TEXT,
company_id INTEGER REFERENCES companies(id),
contract_amount REAL,
start_date TEXT,
end_date TEXT,
resident_name TEXT NOT NULL DEFAULT '',
siroc TEXT NOT NULL DEFAULT '',
payroll_tax_pct REAL NOT NULL DEFAULT 4,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS workers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
first_name TEXT NOT NULL,
middle_name TEXT,
last_name_p TEXT NOT NULL,
last_name_m TEXT NOT NULL,
curp TEXT NOT NULL COLLATE NOCASE,
rfc TEXT NOT NULL COLLATE NOCASE,
nss TEXT NOT NULL,
phone TEXT NOT NULL,
email TEXT NOT NULL,
address TEXT NOT NULL,
blood_type TEXT,
hire_type TEXT NOT NULL,
company_id INTEGER REFERENCES companies(id),
position TEXT NOT NULL,
risk_code TEXT NOT NULL REFERENCES risk_levels(code),
work_type TEXT NOT NULL CHECK (work_type IN ('N', 'D')),
daily_wage REAL NOT NULL DEFAULT 0,
needs_badge INTEGER NOT NULL DEFAULT 1,
status TEXT NOT NULL DEFAULT 'activo' CHECK (status IN ('activo', 'baja')),
pipeline_status TEXT NOT NULL DEFAULT 'incompleto',
imss_status TEXT NOT NULL DEFAULT 'sin_alta' CHECK (imss_status IN ('sin_alta', 'alta', 'baja_imss')),
imss_company_id INTEGER REFERENCES companies(id),
imss_alta_at TEXT,
imss_baja_at TEXT,
last_rehire_at TEXT,
vcard_password_enc TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_workers_curp ON workers(curp);
CREATE UNIQUE INDEX IF NOT EXISTS idx_workers_rfc ON workers(rfc);
CREATE UNIQUE INDEX IF NOT EXISTS idx_workers_nss ON workers(nss);
CREATE TABLE IF NOT EXISTS assignments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
worker_id INTEGER NOT NULL REFERENCES workers(id) ON DELETE CASCADE,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
active INTEGER NOT NULL DEFAULT 1,
start_date TEXT NOT NULL,
end_date TEXT,
UNIQUE (worker_id, project_id)
);
CREATE TABLE IF NOT EXISTS document_types (
code TEXT PRIMARY KEY,
label TEXT NOT NULL,
required INTEGER NOT NULL DEFAULT 1,
validity_mode TEXT NOT NULL DEFAULT 'none' CHECK (validity_mode IN ('none', 'freshness', 'expiry')),
freshness_days INTEGER,
requires_issued_at INTEGER NOT NULL DEFAULT 0,
requires_expires_at INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS documents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
worker_id INTEGER NOT NULL REFERENCES workers(id) ON DELETE CASCADE,
type_code TEXT NOT NULL REFERENCES document_types(code),
original_name TEXT NOT NULL,
mime TEXT NOT NULL,
size_bytes INTEGER NOT NULL,
sha256 TEXT NOT NULL,
iv TEXT NOT NULL,
storage_name TEXT NOT NULL,
is_current INTEGER NOT NULL DEFAULT 1,
parse_status TEXT NOT NULL DEFAULT 'manual',
issued_at TEXT,
expires_at TEXT,
imss_company_id INTEGER REFERENCES companies(id),
imss_alta_at TEXT,
uploaded_by INTEGER REFERENCES users(id),
uploaded_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS project_document_types (
code TEXT PRIMARY KEY,
label TEXT NOT NULL,
required INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE IF NOT EXISTS project_documents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
type_code TEXT NOT NULL REFERENCES project_document_types(code),
original_name TEXT NOT NULL,
mime TEXT NOT NULL,
size_bytes INTEGER NOT NULL,
sha256 TEXT NOT NULL,
iv TEXT NOT NULL,
storage_name TEXT NOT NULL,
is_current INTEGER NOT NULL DEFAULT 1,
parse_status TEXT NOT NULL DEFAULT 'manual',
uploaded_by INTEGER REFERENCES users(id),
uploaded_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS badge_jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL REFERENCES projects(id),
status TEXT NOT NULL DEFAULT 'done',
pdf_path TEXT,
created_by INTEGER REFERENCES users(id),
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS badge_job_people (
job_id INTEGER NOT NULL REFERENCES badge_jobs(id) ON DELETE CASCADE,
worker_id INTEGER NOT NULL REFERENCES workers(id),
delivered INTEGER NOT NULL DEFAULT 0,
delivered_at TEXT,
PRIMARY KEY (job_id, worker_id)
);
CREATE TABLE IF NOT EXISTS loans (
id INTEGER PRIMARY KEY AUTOINCREMENT,
worker_id INTEGER NOT NULL REFERENCES workers(id) ON DELETE CASCADE,
amount REAL NOT NULL,
balance REAL NOT NULL,
weekly_payment REAL NOT NULL,
note TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS attendance (
id INTEGER PRIMARY KEY AUTOINCREMENT,
worker_id INTEGER NOT NULL REFERENCES workers(id) ON DELETE CASCADE,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
work_date TEXT NOT NULL,
present INTEGER NOT NULL DEFAULT 1,
UNIQUE (worker_id, project_id, work_date)
);
CREATE TABLE IF NOT EXISTS payroll_periods (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL REFERENCES projects(id),
week_start TEXT NOT NULL,
week_end TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'draft',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS payroll_lines (
id INTEGER PRIMARY KEY AUTOINCREMENT,
period_id INTEGER NOT NULL REFERENCES payroll_periods(id) ON DELETE CASCADE,
worker_id INTEGER NOT NULL REFERENCES workers(id),
days REAL NOT NULL DEFAULT 0,
daily_wage REAL NOT NULL,
gross REAL NOT NULL,
discounts REAL NOT NULL DEFAULT 0,
loan_payment REAL NOT NULL DEFAULT 0,
net REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS budget_chapters (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
parent_id INTEGER REFERENCES budget_chapters(id) ON DELETE SET NULL,
code TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL,
wbs TEXT NOT NULL DEFAULT '',
sort_order INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS budget_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
chapter_id INTEGER REFERENCES budget_chapters(id) ON DELETE SET NULL,
code TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL,
unit TEXT NOT NULL DEFAULT '',
quantity REAL NOT NULL DEFAULT 0,
unit_price REAL NOT NULL DEFAULT 0,
amount REAL NOT NULL DEFAULT 0,
wbs TEXT NOT NULL DEFAULT '',
sort_order INTEGER NOT NULL DEFAULT 0
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_code ON projects(code);

View file

@ -1,43 +0,0 @@
--liquibase formatted sql
--changeset panel:app-002a-users-tenant endDelimiter:;
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pragma_table_info('users') WHERE name='tenant_id'
ALTER TABLE users ADD COLUMN tenant_id INTEGER;
--changeset panel:app-002b-users-role endDelimiter:;
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pragma_table_info('users') WHERE name='role'
ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user';
--changeset panel:app-002c-users-company endDelimiter:;
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pragma_table_info('users') WHERE name='company_id'
ALTER TABLE users ADD COLUMN company_id INTEGER REFERENCES companies(id);
--changeset panel:app-002d-companies-tenant endDelimiter:;
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pragma_table_info('companies') WHERE name='tenant_id'
ALTER TABLE companies ADD COLUMN tenant_id INTEGER;
--changeset panel:app-002e-workers-tenant endDelimiter:;
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pragma_table_info('workers') WHERE name='tenant_id'
ALTER TABLE workers ADD COLUMN tenant_id INTEGER;
--changeset panel:app-002f-projects-tenant endDelimiter:;
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pragma_table_info('projects') WHERE name='tenant_id'
ALTER TABLE projects ADD COLUMN tenant_id INTEGER;
--changeset panel:app-002g-backfill-tenant endDelimiter:; splitStatements:true
UPDATE companies SET tenant_id = 1 WHERE tenant_id IS NULL;
UPDATE workers SET tenant_id = 1 WHERE tenant_id IS NULL;
UPDATE projects SET tenant_id = 1 WHERE tenant_id IS NULL;
UPDATE users SET tenant_id = 1 WHERE tenant_id IS NULL;
UPDATE users SET role = 'tenant_admin' WHERE COALESCE(role, 'user') = 'user';
CREATE INDEX IF NOT EXISTS idx_companies_tenant ON companies(tenant_id);
CREATE INDEX IF NOT EXISTS idx_workers_tenant ON workers(tenant_id);
CREATE INDEX IF NOT EXISTS idx_projects_tenant ON projects(tenant_id);
CREATE INDEX IF NOT EXISTS idx_users_tenant ON users(tenant_id);

View file

@ -1,11 +0,0 @@
--liquibase formatted sql
--changeset panel:app-003a-must-change-password endDelimiter:;
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pragma_table_info('users') WHERE name='must_change_password'
ALTER TABLE users ADD COLUMN must_change_password INTEGER NOT NULL DEFAULT 0;
--changeset panel:app-003b-user-email endDelimiter:;
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pragma_table_info('users') WHERE name='email'
ALTER TABLE users ADD COLUMN email TEXT NOT NULL DEFAULT '';

View file

@ -1,40 +0,0 @@
--liquibase formatted sql
--changeset panel:app-004a-doc-type-category endDelimiter:;
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pragma_table_info('document_types') WHERE name='category'
ALTER TABLE document_types ADD COLUMN category TEXT NOT NULL DEFAULT 'identidad';
--changeset panel:app-004b-project-doc-category endDelimiter:;
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pragma_table_info('project_document_types') WHERE name='category'
ALTER TABLE project_document_types ADD COLUMN category TEXT NOT NULL DEFAULT 'contrato';
--changeset panel:app-004c-company-document-types endDelimiter:;
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='company_document_types'
CREATE TABLE company_document_types (
code TEXT PRIMARY KEY,
label TEXT NOT NULL,
required INTEGER NOT NULL DEFAULT 0,
category TEXT NOT NULL DEFAULT 'otro'
);
--changeset panel:app-004d-company-documents endDelimiter:;
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='company_documents'
CREATE TABLE company_documents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
company_id INTEGER NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
type_code TEXT NOT NULL REFERENCES company_document_types(code),
original_name TEXT NOT NULL,
mime TEXT NOT NULL,
size_bytes INTEGER NOT NULL,
sha256 TEXT NOT NULL,
iv TEXT NOT NULL,
storage_name TEXT NOT NULL,
is_current INTEGER NOT NULL DEFAULT 1,
parse_status TEXT NOT NULL DEFAULT 'manual',
uploaded_by INTEGER REFERENCES users(id),
uploaded_at TEXT NOT NULL DEFAULT (datetime('now'))
);

View file

@ -1,164 +0,0 @@
--liquibase formatted sql
--changeset panel:app-005a-payroll-settings endDelimiter:;
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='payroll_settings'
CREATE TABLE payroll_settings (
tenant_id INTEGER NOT NULL PRIMARY KEY,
loan_commission_enabled INTEGER NOT NULL DEFAULT 1,
loan_commission_pct REAL NOT NULL DEFAULT 10,
loan_small_max REAL NOT NULL DEFAULT 500
);
--changeset panel:app-005b-payroll-weeks endDelimiter:;
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='payroll_weeks'
CREATE TABLE payroll_weeks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
week_start TEXT NOT NULL,
week_end TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'assembled', 'paid')),
required_net REAL NOT NULL DEFAULT 0,
payable_net REAL NOT NULL DEFAULT 0,
assembled_at TEXT,
paid_at TEXT,
tenant_id INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE UNIQUE INDEX idx_payroll_weeks_tenant_start ON payroll_weeks(tenant_id, week_start);
--changeset panel:app-005c-payroll-sheets endDelimiter:;
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='payroll_sheets'
CREATE TABLE payroll_sheets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
week_id INTEGER NOT NULL REFERENCES payroll_weeks(id) ON DELETE CASCADE,
kind TEXT NOT NULL CHECK (kind IN ('obra', 'destajo', 'admin')),
project_id INTEGER REFERENCES projects(id)
);
CREATE UNIQUE INDEX idx_payroll_sheets_week_kind_project ON payroll_sheets(week_id, kind, IFNULL(project_id, 0));
--changeset panel:app-005d-payroll-week-lines endDelimiter:;
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='payroll_week_lines'
CREATE TABLE payroll_week_lines (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sheet_id INTEGER NOT NULL REFERENCES payroll_sheets(id) ON DELETE CASCADE,
worker_id INTEGER NOT NULL REFERENCES workers(id),
destajo_cut_line_id INTEGER,
project_id INTEGER REFERENCES projects(id),
days REAL NOT NULL DEFAULT 0,
daily_wage REAL NOT NULL DEFAULT 0,
qty_planned REAL NOT NULL DEFAULT 0,
qty_actual REAL NOT NULL DEFAULT 0,
qty_extra REAL NOT NULL DEFAULT 0,
unit_price REAL NOT NULL DEFAULT 0,
unit_code TEXT,
concepto TEXT,
amount REAL NOT NULL DEFAULT 0,
gross REAL NOT NULL DEFAULT 0,
discounts REAL NOT NULL DEFAULT 0,
loan_id INTEGER REFERENCES loans(id),
loan_discount REAL NOT NULL DEFAULT 0,
loan_label TEXT,
required_net REAL NOT NULL DEFAULT 0,
payable_net REAL NOT NULL DEFAULT 0
);
--changeset panel:app-005e-destajo-units endDelimiter:;
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='destajo_units'
CREATE TABLE destajo_units (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tenant_id INTEGER NOT NULL DEFAULT 0,
code TEXT NOT NULL,
label TEXT NOT NULL,
UNIQUE (tenant_id, code)
);
--changeset panel:app-005f-destajo-periods endDelimiter:;
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='destajo_periods'
CREATE TABLE destajo_periods (
id INTEGER PRIMARY KEY AUTOINCREMENT,
period_start TEXT NOT NULL,
period_end TEXT NOT NULL,
week_id INTEGER NOT NULL REFERENCES payroll_weeks(id) ON DELETE CASCADE,
tenant_id INTEGER NOT NULL DEFAULT 0,
UNIQUE (tenant_id, period_end)
);
--changeset panel:app-005g-destajo-jobs endDelimiter:;
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='destajo_jobs'
CREATE TABLE destajo_jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL REFERENCES projects(id),
worker_id INTEGER NOT NULL REFERENCES workers(id),
concepto TEXT NOT NULL,
unit_code TEXT NOT NULL,
qty_total_estimated REAL NOT NULL,
unit_price REAL NOT NULL,
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'done')),
tenant_id INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
--changeset panel:app-005h-destajo-cut-lines endDelimiter:;
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='destajo_cut_lines'
CREATE TABLE destajo_cut_lines (
id INTEGER PRIMARY KEY AUTOINCREMENT,
period_id INTEGER NOT NULL REFERENCES destajo_periods(id) ON DELETE CASCADE,
job_id INTEGER NOT NULL REFERENCES destajo_jobs(id) ON DELETE CASCADE,
qty_planned REAL NOT NULL DEFAULT 0,
qty_actual REAL NOT NULL DEFAULT 0,
qty_extra REAL NOT NULL DEFAULT 0,
UNIQUE (period_id, job_id)
);
--changeset panel:app-005i-loan-payments endDelimiter:;
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='loan_payments'
CREATE TABLE loan_payments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
loan_id INTEGER NOT NULL REFERENCES loans(id) ON DELETE CASCADE,
week_id INTEGER NOT NULL REFERENCES payroll_weeks(id),
amount REAL NOT NULL,
installment_n INTEGER NOT NULL DEFAULT 1,
label TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
--changeset panel:app-005j-loans-delivered endDelimiter:;
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pragma_table_info('loans') WHERE name='delivered'
ALTER TABLE loans ADD COLUMN delivered REAL NOT NULL DEFAULT 0;
--changeset panel:app-005k-loans-commission-pct endDelimiter:;
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pragma_table_info('loans') WHERE name='commission_pct'
ALTER TABLE loans ADD COLUMN commission_pct REAL NOT NULL DEFAULT 0;
--changeset panel:app-005l-loans-commission-amount endDelimiter:;
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pragma_table_info('loans') WHERE name='commission_amount'
ALTER TABLE loans ADD COLUMN commission_amount REAL NOT NULL DEFAULT 0;
--changeset panel:app-005m-loans-plan endDelimiter:;
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pragma_table_info('loans') WHERE name='plan'
ALTER TABLE loans ADD COLUMN plan TEXT NOT NULL DEFAULT 'single';
--changeset panel:app-005n-loans-installments endDelimiter:;
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pragma_table_info('loans') WHERE name='installments_n'
ALTER TABLE loans ADD COLUMN installments_n INTEGER NOT NULL DEFAULT 1;
--changeset panel:app-005o-loans-first-due endDelimiter:;
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pragma_table_info('loans') WHERE name='first_due'
ALTER TABLE loans ADD COLUMN first_due TEXT;
--changeset panel:app-005p-loans-migrate-delivered endDelimiter:;
UPDATE loans SET delivered = amount WHERE delivered = 0 AND amount > 0;

View file

@ -1,4 +0,0 @@
changeLogFile=changelog-master.xml
driver=org.sqlite.JDBC
url=jdbc:sqlite:../../data/app.db
classpath=../tools/sqlite-jdbc.jar

View file

@ -13,10 +13,10 @@ if [[ ! -x "$TOOLS/liquibase/liquibase" ]]; then
rm -f liquibase.zip
fi
if [[ ! -f "$TOOLS/sqlite-jdbc.jar" ]]; then
echo "Downloading sqlite-jdbc..."
curl -fsSL -o sqlite-jdbc.jar \
"https://repo1.maven.org/maven2/org/xerial/sqlite-jdbc/3.46.1.3/sqlite-jdbc-3.46.1.3.jar"
if [[ ! -f "$TOOLS/postgresql-jdbc.jar" ]]; then
echo "Downloading PostgreSQL JDBC driver..."
curl -fsSL -o postgresql-jdbc.jar \
"https://repo1.maven.org/maven2/org/postgresql/postgresql/42.7.4/postgresql-42.7.4.jar"
fi
echo "Liquibase tools ready."

View file

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.20.xsd">
<!-- panels_product, esquema core: negocio (empresas, personal, obras,
expedientes, presupuesto, nómina, gafetes). -->
<include file="changesets/001-baseline.sql" relativeToChangelogFile="true"/>
<include file="changesets/002-tenant-settings.sql" relativeToChangelogFile="true"/>
<include file="changesets/003-seed-catalogs.sql" relativeToChangelogFile="true"/>
<include file="changesets/004-seed-dev-data.sql" relativeToChangelogFile="true"/>
<include file="changesets/005-rls.sql" relativeToChangelogFile="true"/>
</databaseChangeLog>

View file

@ -0,0 +1,482 @@
--liquibase formatted sql
-- PANELS · panels_product.core (Postgres) · baseline
--
-- Negocio: empresas, personal, obras, expedientes, presupuesto, nómina,
-- gafetes. Sin FK hacia iam (documents.uploaded_by / badge_jobs.created_by
-- son snapshot desnormalizado, no referencia viva) ni hacia
-- panels_platform.tenants (bases distintas -- tenant_id se valida en la
-- capa de aplicación, igual que hoy entre app.db/platform.db).
--
-- Requiere que la extensión citext ya esté instalada en la base de datos
-- (ver db/provision/04-product-database.sql, corre como superuser).
--changeset panel:core-001a-companies endDelimiter:; splitStatements:true
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='companies'
CREATE TABLE core.companies (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
code TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
parent_id INTEGER REFERENCES core.companies(id),
kind TEXT NOT NULL DEFAULT 'sub' CHECK (kind IN ('principal', 'sub')),
status TEXT NOT NULL DEFAULT 'activo' CHECK (status IN ('activo', 'inactivo')),
tenant_id INTEGER,
registro_patronal TEXT NOT NULL DEFAULT '',
razon_social TEXT NOT NULL DEFAULT '',
nombre_comercial TEXT NOT NULL DEFAULT '',
rfc TEXT NOT NULL DEFAULT '',
regimen_fiscal TEXT NOT NULL DEFAULT '',
clase_riesgo TEXT NOT NULL DEFAULT '',
domicilio_fiscal TEXT NOT NULL DEFAULT '',
codigo_postal TEXT NOT NULL DEFAULT '',
ciudad TEXT NOT NULL DEFAULT '',
estado TEXT NOT NULL DEFAULT '',
telefono TEXT NOT NULL DEFAULT '',
email TEXT NOT NULL DEFAULT '',
representante_legal TEXT NOT NULL DEFAULT '',
giro TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_core_companies_tenant ON core.companies(tenant_id);
--changeset panel:core-001b-risk-levels endDelimiter:; splitStatements:true
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='risk_levels'
CREATE TABLE core.risk_levels (
code TEXT PRIMARY KEY,
label TEXT NOT NULL,
color TEXT NOT NULL,
text_color TEXT NOT NULL DEFAULT '#FFFFFF'
);
--changeset panel:core-001c-badge-themes endDelimiter:; splitStatements:true
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='badge_themes'
CREATE TABLE core.badge_themes (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
layout TEXT NOT NULL
);
--changeset panel:core-001d-projects endDelimiter:; splitStatements:true
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='projects'
CREATE TABLE core.projects (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
code TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
address TEXT NOT NULL DEFAULT '',
stage TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'activo' CHECK (status IN ('activo', 'pausado', 'concluido', 'cancelado')),
theme_id TEXT NOT NULL REFERENCES core.badge_themes(id),
logo_left_path TEXT,
logo_right_path TEXT,
company_id INTEGER REFERENCES core.companies(id),
tenant_id INTEGER,
contract_amount NUMERIC(14,2),
start_date DATE,
end_date DATE,
resident_name TEXT NOT NULL DEFAULT '',
siroc TEXT NOT NULL DEFAULT '',
payroll_tax_pct NUMERIC(5,2) NOT NULL DEFAULT 4,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_core_projects_tenant ON core.projects(tenant_id);
--changeset panel:core-001e-workers endDelimiter:; splitStatements:true
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='workers'
CREATE TABLE core.workers (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
first_name TEXT NOT NULL,
middle_name TEXT,
last_name_p TEXT NOT NULL,
last_name_m TEXT NOT NULL,
curp CITEXT NOT NULL UNIQUE,
rfc CITEXT NOT NULL UNIQUE,
nss TEXT NOT NULL UNIQUE,
phone TEXT NOT NULL,
email TEXT NOT NULL,
address TEXT NOT NULL,
blood_type TEXT,
hire_type TEXT NOT NULL,
company_id INTEGER REFERENCES core.companies(id),
tenant_id INTEGER,
position TEXT NOT NULL,
risk_code TEXT NOT NULL REFERENCES core.risk_levels(code),
work_type TEXT NOT NULL CHECK (work_type IN ('N', 'D')),
daily_wage NUMERIC(12,2) NOT NULL DEFAULT 0,
needs_badge BOOLEAN NOT NULL DEFAULT true,
status TEXT NOT NULL DEFAULT 'activo' CHECK (status IN ('activo', 'baja')),
pipeline_status TEXT NOT NULL DEFAULT 'incompleto',
imss_status TEXT NOT NULL DEFAULT 'sin_alta' CHECK (imss_status IN ('sin_alta', 'alta', 'baja_imss')),
imss_company_id INTEGER REFERENCES core.companies(id),
imss_alta_at DATE,
imss_baja_at DATE,
last_rehire_at DATE,
vcard_password_enc TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_core_workers_tenant ON core.workers(tenant_id);
--changeset panel:core-001f-assignments endDelimiter:; splitStatements:true
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='assignments'
CREATE TABLE core.assignments (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
worker_id INTEGER NOT NULL REFERENCES core.workers(id) ON DELETE CASCADE,
project_id INTEGER NOT NULL REFERENCES core.projects(id) ON DELETE CASCADE,
active BOOLEAN NOT NULL DEFAULT true,
start_date DATE NOT NULL,
end_date DATE,
UNIQUE (worker_id, project_id)
);
--changeset panel:core-001g-document-types endDelimiter:; splitStatements:true
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='document_types'
CREATE TABLE core.document_types (
code TEXT PRIMARY KEY,
label TEXT NOT NULL,
required BOOLEAN NOT NULL DEFAULT true,
validity_mode TEXT NOT NULL DEFAULT 'none' CHECK (validity_mode IN ('none', 'freshness', 'expiry')),
freshness_days INTEGER,
requires_issued_at BOOLEAN NOT NULL DEFAULT false,
requires_expires_at BOOLEAN NOT NULL DEFAULT false,
category TEXT NOT NULL DEFAULT 'identidad'
);
--changeset panel:core-001h-documents endDelimiter:; splitStatements:true
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='documents'
--comment: uploaded_by_id/uploaded_by_name son snapshot -- sin FK hacia
-- iam.users (esquemas aislados a propósito).
CREATE TABLE core.documents (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
worker_id INTEGER NOT NULL REFERENCES core.workers(id) ON DELETE CASCADE,
type_code TEXT NOT NULL REFERENCES core.document_types(code),
original_name TEXT NOT NULL,
mime TEXT NOT NULL,
size_bytes BIGINT NOT NULL,
sha256 TEXT NOT NULL,
iv TEXT NOT NULL,
storage_name TEXT NOT NULL,
is_current BOOLEAN NOT NULL DEFAULT true,
parse_status TEXT NOT NULL DEFAULT 'manual',
issued_at DATE,
expires_at DATE,
imss_company_id INTEGER REFERENCES core.companies(id),
imss_alta_at DATE,
uploaded_by_id INTEGER,
uploaded_by_name TEXT NOT NULL DEFAULT '',
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
--changeset panel:core-001i-project-document-types endDelimiter:; splitStatements:true
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='project_document_types'
CREATE TABLE core.project_document_types (
code TEXT PRIMARY KEY,
label TEXT NOT NULL,
required BOOLEAN NOT NULL DEFAULT true,
category TEXT NOT NULL DEFAULT 'contrato'
);
--changeset panel:core-001j-project-documents endDelimiter:; splitStatements:true
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='project_documents'
CREATE TABLE core.project_documents (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
project_id INTEGER NOT NULL REFERENCES core.projects(id) ON DELETE CASCADE,
type_code TEXT NOT NULL REFERENCES core.project_document_types(code),
original_name TEXT NOT NULL,
mime TEXT NOT NULL,
size_bytes BIGINT NOT NULL,
sha256 TEXT NOT NULL,
iv TEXT NOT NULL,
storage_name TEXT NOT NULL,
is_current BOOLEAN NOT NULL DEFAULT true,
parse_status TEXT NOT NULL DEFAULT 'manual',
uploaded_by_id INTEGER,
uploaded_by_name TEXT NOT NULL DEFAULT '',
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
--changeset panel:core-001k-company-document-types endDelimiter:; splitStatements:true
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='company_document_types'
CREATE TABLE core.company_document_types (
code TEXT PRIMARY KEY,
label TEXT NOT NULL,
required BOOLEAN NOT NULL DEFAULT false,
category TEXT NOT NULL DEFAULT 'otro'
);
--changeset panel:core-001l-company-documents endDelimiter:; splitStatements:true
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='company_documents'
CREATE TABLE core.company_documents (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
company_id INTEGER NOT NULL REFERENCES core.companies(id) ON DELETE CASCADE,
type_code TEXT NOT NULL REFERENCES core.company_document_types(code),
original_name TEXT NOT NULL,
mime TEXT NOT NULL,
size_bytes BIGINT NOT NULL,
sha256 TEXT NOT NULL,
iv TEXT NOT NULL,
storage_name TEXT NOT NULL,
is_current BOOLEAN NOT NULL DEFAULT true,
parse_status TEXT NOT NULL DEFAULT 'manual',
uploaded_by_id INTEGER,
uploaded_by_name TEXT NOT NULL DEFAULT '',
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
--changeset panel:core-001m-badge-jobs endDelimiter:; splitStatements:true
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='badge_jobs'
CREATE TABLE core.badge_jobs (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
project_id INTEGER NOT NULL REFERENCES core.projects(id),
status TEXT NOT NULL DEFAULT 'done',
pdf_path TEXT,
created_by_id INTEGER,
created_by_name TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
--changeset panel:core-001n-badge-job-people endDelimiter:; splitStatements:true
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='badge_job_people'
CREATE TABLE core.badge_job_people (
job_id INTEGER NOT NULL REFERENCES core.badge_jobs(id) ON DELETE CASCADE,
worker_id INTEGER NOT NULL REFERENCES core.workers(id),
delivered BOOLEAN NOT NULL DEFAULT false,
delivered_at TIMESTAMPTZ,
PRIMARY KEY (job_id, worker_id)
);
--changeset panel:core-001o-loans endDelimiter:; splitStatements:true
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='loans'
CREATE TABLE core.loans (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
worker_id INTEGER NOT NULL REFERENCES core.workers(id) ON DELETE CASCADE,
amount NUMERIC(12,2) NOT NULL,
delivered NUMERIC(12,2) NOT NULL DEFAULT 0,
balance NUMERIC(12,2) NOT NULL,
weekly_payment NUMERIC(12,2) NOT NULL,
note TEXT,
commission_pct NUMERIC(5,2) NOT NULL DEFAULT 0,
commission_amount NUMERIC(12,2) NOT NULL DEFAULT 0,
plan TEXT NOT NULL DEFAULT 'single',
installments_n INTEGER NOT NULL DEFAULT 1,
first_due DATE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
--changeset panel:core-001p-attendance endDelimiter:; splitStatements:true
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='attendance'
CREATE TABLE core.attendance (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
worker_id INTEGER NOT NULL REFERENCES core.workers(id) ON DELETE CASCADE,
project_id INTEGER NOT NULL REFERENCES core.projects(id) ON DELETE CASCADE,
work_date DATE NOT NULL,
present BOOLEAN NOT NULL DEFAULT true,
UNIQUE (worker_id, project_id, work_date)
);
--changeset panel:core-001q-payroll-periods endDelimiter:; splitStatements:true
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='payroll_periods'
--comment: Nómina simple por proyecto (legacy, sigue en uso via payroll_http.ts).
CREATE TABLE core.payroll_periods (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
project_id INTEGER NOT NULL REFERENCES core.projects(id),
week_start DATE NOT NULL,
week_end DATE NOT NULL,
status TEXT NOT NULL DEFAULT 'draft',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
--changeset panel:core-001r-payroll-lines endDelimiter:; splitStatements:true
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='payroll_lines'
CREATE TABLE core.payroll_lines (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
period_id INTEGER NOT NULL REFERENCES core.payroll_periods(id) ON DELETE CASCADE,
worker_id INTEGER NOT NULL REFERENCES core.workers(id),
days NUMERIC(5,2) NOT NULL DEFAULT 0,
daily_wage NUMERIC(12,2) NOT NULL,
gross NUMERIC(12,2) NOT NULL,
discounts NUMERIC(12,2) NOT NULL DEFAULT 0,
loan_payment NUMERIC(12,2) NOT NULL DEFAULT 0,
net NUMERIC(12,2) NOT NULL
);
--changeset panel:core-001s-payroll-settings endDelimiter:; splitStatements:true
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='payroll_settings'
CREATE TABLE core.payroll_settings (
tenant_id INTEGER PRIMARY KEY,
loan_commission_enabled BOOLEAN NOT NULL DEFAULT true,
loan_commission_pct NUMERIC(5,2) NOT NULL DEFAULT 10,
loan_small_max NUMERIC(12,2) NOT NULL DEFAULT 500
);
--changeset panel:core-001t-payroll-weeks endDelimiter:; splitStatements:true
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='payroll_weeks'
CREATE TABLE core.payroll_weeks (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
week_start DATE NOT NULL,
week_end DATE NOT NULL,
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'assembled', 'paid')),
required_net NUMERIC(14,2) NOT NULL DEFAULT 0,
payable_net NUMERIC(14,2) NOT NULL DEFAULT 0,
assembled_at TIMESTAMPTZ,
paid_at TIMESTAMPTZ,
tenant_id INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (tenant_id, week_start)
);
CREATE INDEX idx_core_payroll_weeks_tenant ON core.payroll_weeks(tenant_id);
--changeset panel:core-001u-payroll-sheets endDelimiter:; splitStatements:true
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='payroll_sheets'
CREATE TABLE core.payroll_sheets (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
week_id INTEGER NOT NULL REFERENCES core.payroll_weeks(id) ON DELETE CASCADE,
kind TEXT NOT NULL CHECK (kind IN ('obra', 'destajo', 'admin')),
project_id INTEGER REFERENCES core.projects(id)
);
CREATE UNIQUE INDEX idx_core_payroll_sheets_week_kind_project
ON core.payroll_sheets(week_id, kind, COALESCE(project_id, 0));
--changeset panel:core-001v-payroll-week-lines endDelimiter:; splitStatements:true
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='payroll_week_lines'
CREATE TABLE core.payroll_week_lines (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
sheet_id INTEGER NOT NULL REFERENCES core.payroll_sheets(id) ON DELETE CASCADE,
worker_id INTEGER NOT NULL REFERENCES core.workers(id),
destajo_cut_line_id INTEGER,
project_id INTEGER REFERENCES core.projects(id),
days NUMERIC(5,2) NOT NULL DEFAULT 0,
daily_wage NUMERIC(12,2) NOT NULL DEFAULT 0,
qty_planned NUMERIC(14,4) NOT NULL DEFAULT 0,
qty_actual NUMERIC(14,4) NOT NULL DEFAULT 0,
qty_extra NUMERIC(14,4) NOT NULL DEFAULT 0,
unit_price NUMERIC(14,4) NOT NULL DEFAULT 0,
unit_code TEXT,
concepto TEXT,
amount NUMERIC(14,2) NOT NULL DEFAULT 0,
gross NUMERIC(14,2) NOT NULL DEFAULT 0,
discounts NUMERIC(14,2) NOT NULL DEFAULT 0,
loan_id INTEGER REFERENCES core.loans(id),
loan_discount NUMERIC(14,2) NOT NULL DEFAULT 0,
loan_label TEXT,
required_net NUMERIC(14,2) NOT NULL DEFAULT 0,
payable_net NUMERIC(14,2) NOT NULL DEFAULT 0
);
--changeset panel:core-001w-destajo-units endDelimiter:; splitStatements:true
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='destajo_units'
CREATE TABLE core.destajo_units (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant_id INTEGER NOT NULL DEFAULT 0,
code TEXT NOT NULL,
label TEXT NOT NULL,
UNIQUE (tenant_id, code)
);
CREATE INDEX idx_core_destajo_units_tenant ON core.destajo_units(tenant_id);
--changeset panel:core-001x-destajo-periods endDelimiter:; splitStatements:true
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='destajo_periods'
CREATE TABLE core.destajo_periods (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
period_start DATE NOT NULL,
period_end DATE NOT NULL,
week_id INTEGER NOT NULL REFERENCES core.payroll_weeks(id) ON DELETE CASCADE,
tenant_id INTEGER NOT NULL DEFAULT 0,
UNIQUE (tenant_id, period_end)
);
CREATE INDEX idx_core_destajo_periods_tenant ON core.destajo_periods(tenant_id);
--changeset panel:core-001y-destajo-jobs endDelimiter:; splitStatements:true
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='destajo_jobs'
CREATE TABLE core.destajo_jobs (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
project_id INTEGER NOT NULL REFERENCES core.projects(id),
worker_id INTEGER NOT NULL REFERENCES core.workers(id),
concepto TEXT NOT NULL,
unit_code TEXT NOT NULL,
qty_total_estimated NUMERIC(14,4) NOT NULL,
unit_price NUMERIC(14,4) NOT NULL,
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'done')),
tenant_id INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_core_destajo_jobs_tenant ON core.destajo_jobs(tenant_id);
--changeset panel:core-001z-destajo-cut-lines endDelimiter:; splitStatements:true
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='destajo_cut_lines'
CREATE TABLE core.destajo_cut_lines (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
period_id INTEGER NOT NULL REFERENCES core.destajo_periods(id) ON DELETE CASCADE,
job_id INTEGER NOT NULL REFERENCES core.destajo_jobs(id) ON DELETE CASCADE,
qty_planned NUMERIC(14,4) NOT NULL DEFAULT 0,
qty_actual NUMERIC(14,4) NOT NULL DEFAULT 0,
qty_extra NUMERIC(14,4) NOT NULL DEFAULT 0,
UNIQUE (period_id, job_id)
);
--changeset panel:core-001aa-loan-payments endDelimiter:; splitStatements:true
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='loan_payments'
CREATE TABLE core.loan_payments (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
loan_id INTEGER NOT NULL REFERENCES core.loans(id) ON DELETE CASCADE,
week_id INTEGER NOT NULL REFERENCES core.payroll_weeks(id),
amount NUMERIC(12,2) NOT NULL,
installment_n INTEGER NOT NULL DEFAULT 1,
label TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
--changeset panel:core-001ab-budget-chapters endDelimiter:; splitStatements:true
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='budget_chapters'
CREATE TABLE core.budget_chapters (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
project_id INTEGER NOT NULL REFERENCES core.projects(id) ON DELETE CASCADE,
parent_id INTEGER REFERENCES core.budget_chapters(id) ON DELETE SET NULL,
code TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL,
wbs TEXT NOT NULL DEFAULT '',
sort_order INTEGER NOT NULL DEFAULT 0
);
--changeset panel:core-001ac-budget-items endDelimiter:; splitStatements:true
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='budget_items'
CREATE TABLE core.budget_items (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
project_id INTEGER NOT NULL REFERENCES core.projects(id) ON DELETE CASCADE,
chapter_id INTEGER REFERENCES core.budget_chapters(id) ON DELETE SET NULL,
code TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL,
unit TEXT NOT NULL DEFAULT '',
quantity NUMERIC(14,4) NOT NULL DEFAULT 0,
unit_price NUMERIC(14,4) NOT NULL DEFAULT 0,
amount NUMERIC(14,2) NOT NULL DEFAULT 0,
wbs TEXT NOT NULL DEFAULT '',
sort_order INTEGER NOT NULL DEFAULT 0
);

View file

@ -0,0 +1,17 @@
--liquibase formatted sql
-- PANELS · panels_product.core (Postgres) · Fase 4d
--
-- Zona horaria configurable por tenant. Reemplaza el PAYROLL_TZ hardcodeado
-- de api/payroll.ts ("America/Cancun"). Se separa de core.payroll_settings
-- a propósito: la zona horaria la usan también document_validity.ts y
-- cualquier validación de fecha del sistema, no solo nómina.
--changeset panel:core-002a-tenant-settings endDelimiter:; splitStatements:true
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='tenant_settings'
CREATE TABLE core.tenant_settings (
tenant_id INTEGER PRIMARY KEY,
timezone TEXT NOT NULL DEFAULT 'America/Mexico_City',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

View file

@ -0,0 +1,73 @@
--liquibase formatted sql
-- PANELS · panels_product.core (Postgres) · catálogos requeridos
--
-- A diferencia de los datos de demo (changeset siguiente, context:dev),
-- esto NO es data de ejemplo: la app asume que estos catálogos existen en
-- cualquier ambiente (referencian risk_code/theme_id/type_code desde
-- workers/projects/documents). Por eso corre sin context, en todos lados.
--changeset panel:core-003a-risk-levels endDelimiter:; splitStatements:true
INSERT INTO core.risk_levels (code, label, color, text_color) VALUES
('rojo', 'Rojo', '#A20000', '#FFFFFF'),
('amarillo', 'Amarillo', '#EEEE3C', '#000000'),
('azul', 'Azul', '#001485', '#FFFFFF'),
('verde', 'Verde', '#008514', '#FFFFFF'),
('negro', 'Negro', '#000000', '#FFFFFF'),
('naranja', 'Naranja', '#FF5733', '#FFFFFF')
ON CONFLICT (code) DO NOTHING;
--changeset panel:core-003b-badge-themes endDelimiter:; splitStatements:true
INSERT INTO core.badge_themes (id, name, layout) VALUES
('arctec-dos-logos-fold', 'Arctec dos logos (doblez carta)', 'letter-landscape-4-fold')
ON CONFLICT (id) DO NOTHING;
--changeset panel:core-003c-document-types endDelimiter:; splitStatements:true
INSERT INTO core.document_types (code, label, required, category, validity_mode, freshness_days, requires_issued_at, requires_expires_at) VALUES
('foto', 'Foto', true, 'identidad', 'none', NULL, false, false),
('ine', 'INE', true, 'identidad', 'expiry', NULL, false, true),
('curp', 'Constancia CURP', true, 'identidad', 'freshness', 90, true, false),
('nss', 'Constancia NSS', true, 'identidad', 'freshness', 90, true, false),
('rfc', 'Constancia RFC', true, 'identidad', 'freshness', 90, true, false),
('domicilio', 'Comprobante de domicilio', true, 'identidad', 'freshness', 90, true, false),
('alta_imss', 'Alta IMSS', false, 'imss', 'none', NULL, false, false),
('baja_imss', 'Baja IMSS', false, 'imss', 'none', NULL, false, false),
('contrato', 'Contrato laboral', false, 'laboral', 'none', NULL, false, false),
('aviso_privacidad', 'Aviso de privacidad', false, 'laboral', 'none', NULL, false, false),
('dc3_nom031', 'DC-3 NOM-031 (construcción)', false, 'sst', 'expiry', NULL, false, false),
('epp', 'Acuse de entrega de EPP', false, 'sst', 'none', NULL, false, false),
('medico', 'Certificado médico', false, 'sst', 'expiry', NULL, false, false),
('dc3_altura', 'DC-3 trabajo en alturas', false, 'oficio', 'expiry', NULL, false, false),
('dc3_soldadura', 'DC-3 soldadura', false, 'oficio', 'expiry', NULL, false, false),
('dc3_montacargas', 'DC-3 montacargas', false, 'oficio', 'expiry', NULL, false, false),
('licencia', 'Licencia de conducir', false, 'oficio', 'expiry', NULL, false, false),
('cedula', 'Cédula profesional', false, 'oficio', 'none', NULL, false, false),
('otro', 'Otro', false, 'otro', 'none', NULL, false, false)
ON CONFLICT (code) DO NOTHING;
--changeset panel:core-003d-project-document-types endDelimiter:; splitStatements:true
INSERT INTO core.project_document_types (code, label, required, category) VALUES
('contrato', 'Contrato', true, 'contrato'),
('presupuesto', 'Presupuesto', true, 'contrato'),
('planos', 'Planos', false, 'tecnico'),
('licencia_construccion', 'Licencia de construcción', false, 'permisos'),
('uso_suelo', 'Uso de suelo', false, 'permisos'),
('proteccion_civil', 'Anuencia de Protección Civil', false, 'permisos'),
('mia', 'MIA / resolutivo ambiental', false, 'ambiental'),
('siroc_acuse', 'Acuse SIROC', false, 'imss'),
('programa_sst', 'Programa de seguridad', false, 'sst'),
('seguro_obra', 'Seguro de obra', false, 'contrato'),
('fianza', 'Fianza', false, 'contrato'),
('otro', 'Otro', false, 'otro')
ON CONFLICT (code) DO NOTHING;
--changeset panel:core-003e-company-document-types endDelimiter:; splitStatements:true
INSERT INTO core.company_document_types (code, label, required, category) VALUES
('repse_federal', 'REPSE federal (STPS)', false, 'repse'),
('repse_estatal', 'REPSE estatal (SATQ)', false, 'repse'),
('opinion_sat', 'Opinión de cumplimiento SAT (32-D)', false, 'fiscal'),
('opinion_imss', 'Opinión de cumplimiento IMSS', false, 'fiscal'),
('opinion_infonavit', 'Opinión de cumplimiento INFONAVIT', false, 'fiscal'),
('acta_constitutiva', 'Acta constitutiva', false, 'legal'),
('poder_notarial', 'Poder notarial', false, 'legal'),
('otro', 'Otro', false, 'otro')
ON CONFLICT (code) DO NOTHING;

View file

@ -0,0 +1,31 @@
--liquibase formatted sql
-- PANELS · panels_product.core (Postgres) · datos de demostración
--
-- Solo para dev/pruebas -- nunca staging/producción. tenant_id=1 asume el
-- mismo id fijo que db/platform/changesets/001-baseline.sql (changeset
-- platform-001d) le da a ARCT2608; no hay FK real entre bases que lo
-- garantice, así que si cambia uno hay que cambiar el otro.
--changeset panel:core-004a-seed-companies context:dev endDelimiter:; splitStatements:true
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM core.companies WHERE code = 'ARCT2608'
INSERT INTO core.companies (code, name, parent_id, kind, tenant_id, nombre_comercial, razon_social)
VALUES ('ARCT2608', 'ARCTEC', NULL, 'principal', 1, 'ARCTEC', 'ARCTEC');
INSERT INTO core.companies (code, name, parent_id, kind, tenant_id, nombre_comercial, razon_social)
SELECT 'FISICA', 'FISICA', id, 'sub', 1, 'FISICA', 'FISICA' FROM core.companies WHERE code = 'ARCT2608';
INSERT INTO core.companies (code, name, parent_id, kind, tenant_id, nombre_comercial, razon_social)
SELECT 'ARCOTEC', 'ARCOTEC', id, 'sub', 1, 'ARCOTEC', 'ARCOTEC' FROM core.companies WHERE code = 'ARCT2608';
--changeset panel:core-004b-seed-project context:dev endDelimiter:; splitStatements:true
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM core.projects WHERE code = 'PRY-0001'
INSERT INTO core.projects (code, name, address, theme_id, company_id, tenant_id)
SELECT 'PRY-0001', 'ZENDALA CANCUN', '', 'arctec-dos-logos-fold', id, 1
FROM core.companies WHERE code = 'ARCT2608';
--changeset panel:core-004c-seed-tenant-settings context:dev endDelimiter:;
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM core.tenant_settings WHERE tenant_id = 1
INSERT INTO core.tenant_settings (tenant_id, timezone) VALUES (1, 'America/Cancun');

View file

@ -0,0 +1,71 @@
--liquibase formatted sql
-- PANELS · panels_product.core (Postgres) · Row Level Security (Fase 4b)
--
-- Mismo mecanismo que iam (ver db/iam/changesets/002-rls.sql): el rol de
-- runtime (panels_core_app) no es dueño de las tablas, así que
-- ENABLE ROW LEVEL SECURITY ya lo restringe sin afectar a panels_core_owner
-- (que sí necesita ver todos los tenants para migrar/hacer backfills).
--
-- Alcance: se aplica a las tablas con columna tenant_id directa --
-- companies, workers, projects, payroll_weeks, destajo_units/periods/jobs.
-- Tablas hijas sin tenant_id propio (assignments, documents, budget_items,
-- loans, attendance, badge_jobs, payroll_week_lines, etc.) dependen del
-- scoping corregido en la capa de aplicación (Fase 3, cierre del IDOR) via
-- su FK a projects/workers -- extender RLS a esas tablas con políticas por
-- subquery es un endurecimiento futuro válido, no bloqueante para esta fase.
--changeset panel:core-005a-rls-companies endDelimiter:; splitStatements:true
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pg_policies WHERE schemaname='core' AND tablename='companies' AND policyname='tenant_isolation'
ALTER TABLE core.companies ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON core.companies
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer)
WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer);
--changeset panel:core-005b-rls-workers endDelimiter:; splitStatements:true
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pg_policies WHERE schemaname='core' AND tablename='workers' AND policyname='tenant_isolation'
ALTER TABLE core.workers ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON core.workers
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer)
WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer);
--changeset panel:core-005c-rls-projects endDelimiter:; splitStatements:true
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pg_policies WHERE schemaname='core' AND tablename='projects' AND policyname='tenant_isolation'
ALTER TABLE core.projects ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON core.projects
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer)
WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer);
--changeset panel:core-005d-rls-payroll-weeks endDelimiter:; splitStatements:true
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pg_policies WHERE schemaname='core' AND tablename='payroll_weeks' AND policyname='tenant_isolation'
ALTER TABLE core.payroll_weeks ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON core.payroll_weeks
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer)
WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer);
--changeset panel:core-005e-rls-destajo-units endDelimiter:; splitStatements:true
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pg_policies WHERE schemaname='core' AND tablename='destajo_units' AND policyname='tenant_isolation'
ALTER TABLE core.destajo_units ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON core.destajo_units
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer)
WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer);
--changeset panel:core-005f-rls-destajo-periods endDelimiter:; splitStatements:true
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pg_policies WHERE schemaname='core' AND tablename='destajo_periods' AND policyname='tenant_isolation'
ALTER TABLE core.destajo_periods ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON core.destajo_periods
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer)
WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer);
--changeset panel:core-005g-rls-destajo-jobs endDelimiter:; splitStatements:true
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pg_policies WHERE schemaname='core' AND tablename='destajo_jobs' AND policyname='tenant_isolation'
ALTER TABLE core.destajo_jobs ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON core.destajo_jobs
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer)
WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer);

View file

@ -0,0 +1,9 @@
# PANELS · Liquibase · panels_product, esquema core (Postgres)
#
# url/username/password se pasan por variable de entorno o -D en la línea
# de comandos (ver db/update.sh); no se commitean credenciales aquí.
changelogFile=changelog-master.xml
liquibaseSchemaName=core
defaultSchemaName=core
driver=org.postgresql.Driver
classpath=../tools/postgresql-jdbc.jar

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.20.xsd">
<!-- panels_product, esquema iam: identidad/roles/permisos del tenant. -->
<include file="changesets/001-baseline.sql" relativeToChangelogFile="true"/>
<include file="changesets/002-rls.sql" relativeToChangelogFile="true"/>
</databaseChangeLog>

View file

@ -0,0 +1,92 @@
--liquibase formatted sql
-- PANELS · panels_product.iam (Postgres) · baseline
--
-- Identidad y RBAC DE CADA TENANT (ej. empleados de ARCTEC que usan
-- web-panel). No confundir con panels_platform.platform_users (identidad
-- de PANELS como operador SaaS) -- son dos sistemas de identidad distintos
-- que no comparten tabla ni base de datos.
--
-- Este esquema NO tiene FK hacia `core` (ver reglas del monolito modular
-- en el plan): documents.uploaded_by/badge_jobs.created_by pasan a
-- snapshot desnormalizado en `core`, y users.company_id es una referencia
-- lógica a core.companies(id) sin FK real.
--changeset panel:iam-001a-roles endDelimiter:; splitStatements:true
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='iam' AND table_name='roles'
CREATE TABLE iam.roles (
code TEXT PRIMARY KEY,
label TEXT NOT NULL
);
INSERT INTO iam.roles (code, label) VALUES
('tenant_admin', 'Administrador del tenant'),
('user', 'Usuario operativo');
--changeset panel:iam-001b-permissions endDelimiter:; splitStatements:true
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='iam' AND table_name='permissions'
--comment: Catálogo base de permisos. El código de la app hoy sigue gateando
-- por role_code (igual que el string "role" anterior); esta tabla es la
-- base para pasar a chequeos granulares por permiso de forma incremental,
-- sin bloquear la migración a Postgres en ese rediseño más grande.
CREATE TABLE iam.permissions (
code TEXT PRIMARY KEY,
label TEXT NOT NULL
);
INSERT INTO iam.permissions (code, label) VALUES
('manage_workers', 'Alta, edición y expediente de personal'),
('manage_projects', 'Alta y edición de obras'),
('manage_companies', 'Alta y edición de empresas'),
('manage_budget', 'Presupuesto de obra'),
('manage_payroll', 'Nómina y préstamos'),
('manage_documents', 'Documentos de personal/obra/empresa'),
('manage_users', 'Alta y edición de usuarios del tenant'),
('manage_settings', 'Configuración del tenant (zona horaria, etc.)'),
('view_reports', 'Reportes y tableros');
--changeset panel:iam-001c-role-permissions endDelimiter:; splitStatements:true
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='iam' AND table_name='role_permissions'
CREATE TABLE iam.role_permissions (
role_code TEXT NOT NULL REFERENCES iam.roles(code) ON DELETE CASCADE,
permission_code TEXT NOT NULL REFERENCES iam.permissions(code) ON DELETE CASCADE,
PRIMARY KEY (role_code, permission_code)
);
INSERT INTO iam.role_permissions (role_code, permission_code)
SELECT 'tenant_admin', code FROM iam.permissions;
INSERT INTO iam.role_permissions (role_code, permission_code) VALUES
('user', 'manage_workers'),
('user', 'manage_documents'),
('user', 'view_reports');
--changeset panel:iam-001d-users endDelimiter:; splitStatements:true
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='iam' AND table_name='users'
CREATE TABLE iam.users (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
display_name TEXT NOT NULL,
-- Referencia lógica a core.companies(id) -- SIN FK real (iam y core están
-- aislados a propósito; ver reglas del monolito modular).
company_id INTEGER,
-- Referencia lógica a panels_platform.tenants(id) -- SIN FK real (bases
-- de datos distintas, Postgres no permite FK entre databases).
tenant_id INTEGER,
role_code TEXT NOT NULL DEFAULT 'user' REFERENCES iam.roles(code),
must_change_password BOOLEAN NOT NULL DEFAULT false,
email TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_iam_users_tenant ON iam.users(tenant_id);
-- Nota: el primer usuario (platform_admin y el tenant_admin de cada tenant
-- nuevo) NO se siembra aquí. Un password_hash real requiere PBKDF2 (mismo
-- algoritmo que api/crypto.ts), que Liquibase/SQL no puede calcular. Ese
-- bootstrap es un comando explícito y one-shot: scripts/bootstrap-admin.ts
-- (Fase 4), no lógica de seed en el arranque de la app ni en un changeset.

View file

@ -0,0 +1,28 @@
--liquibase formatted sql
-- PANELS · panels_product.iam (Postgres) · Row Level Security (Fase 4b)
--
-- Defensa en profundidad contra el bug de IDOR cross-tenant encontrado en
-- la revisión de seguridad: aunque un handler olvide el WHERE tenant_id=?,
-- Postgres ya no devuelve/permite escribir filas de otro tenant.
--
-- Cómo se activa: el rol de runtime (panels_iam_app) NO es dueño de la
-- tabla (panels_iam_owner sí lo es), así que ENABLE ROW LEVEL SECURITY ya
-- restringe automáticamente a panels_iam_app sin necesitar FORCE (FORCE
-- haría que la política también aplique al dueño, lo cual rompería las
-- migraciones/backfills que sí necesitan ver todos los tenants).
--
-- El valor de comparación viene de un parámetro de sesión que la API fija
-- al inicio de cada transacción: SET LOCAL app.tenant_id = '<id>'. Si el
-- código olvida fijarlo, current_setting(...) devuelve NULL y la
-- comparación "tenant_id = NULL" es siempre falsa -- es decir, "fail
-- closed": sin tenant_id explícito no se ve ni se escribe ninguna fila,
-- en vez de exponer todo por defecto.
--changeset panel:iam-002a-rls-users endDelimiter:; splitStatements:true
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pg_policies WHERE schemaname='iam' AND tablename='users' AND policyname='tenant_isolation'
ALTER TABLE iam.users ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON iam.users
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer)
WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer);

View file

@ -0,0 +1,11 @@
# PANELS · Liquibase · panels_product, esquema iam (Postgres)
#
# url/username/password se pasan por variable de entorno o -D en la línea
# de comandos (ver db/update.sh); no se commitean credenciales aquí.
# liquibaseSchemaName controla dónde vive el DATABASECHANGELOG de este
# módulo -- separado del de `core`, aunque compartan la misma base de datos.
changelogFile=changelog-master.xml
liquibaseSchemaName=iam
defaultSchemaName=iam
driver=org.postgresql.Driver
classpath=../tools/postgresql-jdbc.jar

View file

@ -3,13 +3,9 @@
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
https://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.20.xsd">
<!-- panels_platform: control plane SaaS. Un solo esquema (public). -->
<include file="changesets/001-baseline.sql" relativeToChangelogFile="true"/>
<include file="changesets/001-init.sql" relativeToChangelogFile="true"/>
<include file="changesets/002-seed-arctec-tenant.sql" relativeToChangelogFile="true"/>
<include file="changesets/003-tenant-license.sql" relativeToChangelogFile="true"/>
<include file="changesets/004-tenant-status-expand.sql" relativeToChangelogFile="true"/>
<include file="changesets/005-contact-access.sql" relativeToChangelogFile="true"/>
<include file="changesets/006-tenant-code-date.sql" relativeToChangelogFile="true"/>
<include file="changesets/007-smtp-settings.sql" relativeToChangelogFile="true"/>
</databaseChangeLog>

View file

@ -0,0 +1,66 @@
--liquibase formatted sql
-- PANELS · panels_platform (Postgres) · baseline
--
-- Control plane SaaS: tenants, operadores de PANELS (platform_users) y
-- configuración SMTP. Base de datos físicamente separada de panels_product
-- (ver plan de migración) -- nada aquí se referencia desde iam/core con FK.
--
-- Este baseline representa el ESTADO FINAL equivalente a los changesets
-- SQLite 001..007 de la implementación anterior, no un replay literal
-- (varios de esos changesets eran parches específicos de SQLite, p. ej.
-- el rebuild de tabla en 004-tenant-status-expand.sql para ampliar un CHECK).
--changeset panel:platform-001a-tenants endDelimiter:;
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='public' AND table_name='tenants'
CREATE TABLE tenants (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
code TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'activo' CHECK (status IN ('activo', 'suspendido', 'cancelado')),
plan TEXT NOT NULL DEFAULT 'trial',
valid_until DATE,
notes TEXT NOT NULL DEFAULT '',
contact_email TEXT NOT NULL DEFAULT '',
contact_name TEXT NOT NULL DEFAULT '',
access_sent_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
--changeset panel:platform-001b-platform-users endDelimiter:;
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='public' AND table_name='platform_users'
CREATE TABLE platform_users (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
display_name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'activo' CHECK (status IN ('activo', 'inactivo')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
--changeset panel:platform-001c-smtp-settings endDelimiter:; splitStatements:true
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='public' AND table_name='smtp_settings'
CREATE TABLE smtp_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
host TEXT NOT NULL DEFAULT '',
port INTEGER NOT NULL DEFAULT 587,
username TEXT NOT NULL DEFAULT '',
password TEXT NOT NULL DEFAULT '',
from_address TEXT NOT NULL DEFAULT '',
enabled BOOLEAN NOT NULL DEFAULT false,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
INSERT INTO smtp_settings (id, host, port, username, password, from_address, enabled)
VALUES (1, '', 587, '', '', '', false);
--changeset panel:platform-001d-seed-arctec-tenant context:dev endDelimiter:; splitStatements:true
--comment: Datos de demostración -- nunca en staging/producción. id=1 fijo a
-- propósito: los changesets de dev-seed de core/iam asumen este mismo id
-- para tenant_id (no hay FK real entre bases, así que se sincroniza a mano).
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM tenants WHERE code = 'ARCT2608'
INSERT INTO tenants (id, code, name, status) OVERRIDING SYSTEM VALUE VALUES (1, 'ARCT2608', 'ARCTEC', 'activo');
SELECT setval(pg_get_serial_sequence('tenants', 'id'), (SELECT MAX(id) FROM tenants));

View file

@ -1,20 +0,0 @@
--liquibase formatted sql
--changeset panel:platform-001-init endDelimiter:; splitStatements:true
CREATE TABLE IF NOT EXISTS tenants (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'activo' CHECK (status IN ('activo', 'inactivo')),
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS platform_users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
display_name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'activo' CHECK (status IN ('activo', 'inactivo')),
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);

View file

@ -1,7 +0,0 @@
--liquibase formatted sql
--changeset panel:platform-002-seed-arctec endDelimiter:; splitStatements:true
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM tenants WHERE code = 'ARCTEC'
INSERT INTO tenants (id, code, name, status) VALUES (1, 'ARCTEC', 'ARCTEC', 'activo');

View file

@ -1,16 +0,0 @@
--liquibase formatted sql
--changeset panel:platform-003a-plan endDelimiter:;
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pragma_table_info('tenants') WHERE name='plan'
ALTER TABLE tenants ADD COLUMN plan TEXT NOT NULL DEFAULT 'trial';
--changeset panel:platform-003b-valid-until endDelimiter:;
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pragma_table_info('tenants') WHERE name='valid_until'
ALTER TABLE tenants ADD COLUMN valid_until TEXT;
--changeset panel:platform-003c-notes endDelimiter:;
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pragma_table_info('tenants') WHERE name='notes'
ALTER TABLE tenants ADD COLUMN notes TEXT NOT NULL DEFAULT '';

View file

@ -1,30 +0,0 @@
--liquibase formatted sql
--changeset panel:platform-004-status-expand endDelimiter:; splitStatements:true
--comment: Expand tenant status to activo|suspendido|cancelado (SQLite rebuild)
CREATE TABLE tenants_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'activo' CHECK (status IN ('activo', 'suspendido', 'cancelado')),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
plan TEXT NOT NULL DEFAULT 'trial',
valid_until TEXT,
notes TEXT NOT NULL DEFAULT ''
);
INSERT INTO tenants_new (id, code, name, status, created_at, plan, valid_until, notes)
SELECT
id,
code,
name,
CASE WHEN status = 'inactivo' THEN 'suspendido' ELSE status END,
created_at,
COALESCE(plan, 'trial'),
valid_until,
COALESCE(notes, '')
FROM tenants;
DROP TABLE tenants;
ALTER TABLE tenants_new RENAME TO tenants;

View file

@ -1,16 +0,0 @@
--liquibase formatted sql
--changeset panel:platform-005a-contact-email endDelimiter:;
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pragma_table_info('tenants') WHERE name='contact_email'
ALTER TABLE tenants ADD COLUMN contact_email TEXT NOT NULL DEFAULT '';
--changeset panel:platform-005b-contact-name endDelimiter:;
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pragma_table_info('tenants') WHERE name='contact_name'
ALTER TABLE tenants ADD COLUMN contact_name TEXT NOT NULL DEFAULT '';
--changeset panel:platform-005c-access-sent-at endDelimiter:;
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pragma_table_info('tenants') WHERE name='access_sent_at'
ALTER TABLE tenants ADD COLUMN access_sent_at TEXT;

View file

@ -1,7 +0,0 @@
--liquibase formatted sql
--changeset panel:platform-006-tenant-code-date endDelimiter:; splitStatements:true
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:1 SELECT COUNT(*) FROM tenants WHERE code = 'ARCTEC'
UPDATE tenants SET code = 'ARCT2608' WHERE code = 'ARCTEC';

View file

@ -1,18 +0,0 @@
--liquibase formatted sql
--changeset panel:platform-007-smtp-settings endDelimiter:; splitStatements:true
CREATE TABLE IF NOT EXISTS smtp_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
host TEXT NOT NULL DEFAULT '',
port INTEGER NOT NULL DEFAULT 587,
username TEXT NOT NULL DEFAULT '',
password TEXT NOT NULL DEFAULT '',
from_address TEXT NOT NULL DEFAULT '',
enabled INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
INSERT INTO smtp_settings (id, host, port, username, password, from_address, enabled)
SELECT 1, '', 587, '', '', '', 0
WHERE NOT EXISTS (SELECT 1 FROM smtp_settings WHERE id = 1);

View file

@ -1,4 +1,8 @@
changeLogFile=changelog-master.xml
driver=org.sqlite.JDBC
url=jdbc:sqlite:../../data/platform.db
classpath=../tools/sqlite-jdbc.jar
# PANELS · Liquibase · panels_platform (Postgres)
#
# url/username/password se pasan por variable de entorno o -D en la línea
# de comandos (ver db/update.sh); no se commitean credenciales aquí.
changelogFile=changelog-master.xml
liquibaseSchemaName=public
driver=org.postgresql.Driver
classpath=../tools/postgresql-jdbc.jar

51
db/provision/01-roles.sql Normal file
View file

@ -0,0 +1,51 @@
-- PANELS · Postgres · Fase 0 (aprovisionamiento de roles)
--
-- Crea los 6 roles cluster-wide usados por PANELS: dos por módulo
-- (platform, iam, core), un rol "_owner" para Liquibase/migraciones y un
-- rol "_app" para runtime con privilegios acotados a su esquema/base.
--
-- Idempotente vía el idiom \gexec (solo emite el CREATE ROLE si todavía
-- no existe). Nota: la interpolación de variables de psql (:'var') NO
-- funciona dentro de bloques DO $$...$$ (dollar-quoting), por eso este
-- script arma el DDL con format(...)+\gexec en vez de un DO block.
--
-- Uso (contra un Postgres nuevo, como superuser/admin de Coolify). Los
-- valores se pasan SIN comillas propias; :'var' ya produce un literal SQL
-- correctamente escapado:
-- psql "$SUPERUSER_URL" \
-- -v platform_owner_pw="CAMBIA-ESTA-CLAVE-1" \
-- -v platform_app_pw="CAMBIA-ESTA-CLAVE-2" \
-- -v iam_owner_pw="CAMBIA-ESTA-CLAVE-3" \
-- -v iam_app_pw="CAMBIA-ESTA-CLAVE-4" \
-- -v core_owner_pw="CAMBIA-ESTA-CLAVE-5" \
-- -v core_app_pw="CAMBIA-ESTA-CLAVE-6" \
-- -f 01-roles.sql
--
-- Genera claves fuertes por ambiente con: openssl rand -hex 24
SELECT format('CREATE ROLE panels_platform_owner LOGIN PASSWORD %L NOSUPERUSER NOCREATEDB NOCREATEROLE', :'platform_owner_pw')
WHERE NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'panels_platform_owner')\gexec
SELECT format('CREATE ROLE panels_platform_app LOGIN PASSWORD %L NOSUPERUSER NOCREATEDB NOCREATEROLE', :'platform_app_pw')
WHERE NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'panels_platform_app')\gexec
SELECT format('CREATE ROLE panels_iam_owner LOGIN PASSWORD %L NOSUPERUSER NOCREATEDB NOCREATEROLE', :'iam_owner_pw')
WHERE NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'panels_iam_owner')\gexec
SELECT format('CREATE ROLE panels_iam_app LOGIN PASSWORD %L NOSUPERUSER NOCREATEDB NOCREATEROLE', :'iam_app_pw')
WHERE NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'panels_iam_app')\gexec
SELECT format('CREATE ROLE panels_core_owner LOGIN PASSWORD %L NOSUPERUSER NOCREATEDB NOCREATEROLE', :'core_owner_pw')
WHERE NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'panels_core_owner')\gexec
SELECT format('CREATE ROLE panels_core_app LOGIN PASSWORD %L NOSUPERUSER NOCREATEDB NOCREATEROLE', :'core_app_pw')
WHERE NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'panels_core_app')\gexec
-- Si el rol ya existía de una corrida anterior, refresca su password al
-- valor actual (para poder rotar credenciales corriendo este script de nuevo).
ALTER ROLE panels_platform_owner PASSWORD :'platform_owner_pw';
ALTER ROLE panels_platform_app PASSWORD :'platform_app_pw';
ALTER ROLE panels_iam_owner PASSWORD :'iam_owner_pw';
ALTER ROLE panels_iam_app PASSWORD :'iam_app_pw';
ALTER ROLE panels_core_owner PASSWORD :'core_owner_pw';
ALTER ROLE panels_core_app PASSWORD :'core_app_pw';

View file

@ -0,0 +1,33 @@
-- PANELS · Postgres · Fase 0 (aprovisionamiento de bases)
--
-- Crea las dos bases de datos físicamente separadas del monolito modular:
-- panels_platform -> control plane SaaS (tenants, platform_users, smtp_settings)
-- panels_product -> producto que usan los tenants (esquemas iam/core dentro)
--
-- Requiere haber corrido antes 01-roles.sql. Idempotente vía el idiom
-- \gexec (solo emite el CREATE DATABASE si todavía no existe).
--
-- Uso: psql "$SUPERUSER_URL" -f 02-databases.sql
SELECT 'CREATE DATABASE panels_platform OWNER panels_platform_owner'
WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'panels_platform')\gexec
SELECT 'CREATE DATABASE panels_product OWNER panels_iam_owner'
WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'panels_product')\gexec
-- panels_core_owner también necesita poder crear su esquema dentro de panels_product,
-- aunque el owner nominal de la base sea panels_iam_owner.
GRANT CREATE ON DATABASE panels_product TO panels_core_owner;
REVOKE CREATE ON DATABASE panels_product FROM PUBLIC;
REVOKE CREATE ON DATABASE panels_platform FROM PUBLIC;
-- Postgres otorga CONNECT a PUBLIC por defecto en toda base nueva: sin este
-- REVOKE, panels_platform_app podría autenticarse contra panels_product (o
-- viceversa) aunque no tenga privilegios dentro -- justo el cruce que este
-- diseño busca evitar. Se revoca de PUBLIC y se otorga solo a los roles que
-- corresponden a cada base.
REVOKE CONNECT ON DATABASE panels_platform FROM PUBLIC;
GRANT CONNECT ON DATABASE panels_platform TO panels_platform_owner, panels_platform_app;
REVOKE CONNECT ON DATABASE panels_product FROM PUBLIC;
GRANT CONNECT ON DATABASE panels_product TO panels_iam_owner, panels_iam_app, panels_core_owner, panels_core_app;

View file

@ -0,0 +1,25 @@
-- PANELS · Postgres · Fase 0 (privilegios dentro de panels_platform)
--
-- Correr conectado A LA BASE panels_platform (no a "postgres"), como
-- superuser/admin: psql "$SUPERUSER_URL/panels_platform" -f 03-platform-database.sql
--
-- panels_platform es de un solo esquema (public) porque es un control
-- plane simple (tenants, platform_users, smtp_settings) usado solo por
-- las rutas /v1/saas/*. No se subdivide en más esquemas.
REVOKE ALL ON SCHEMA public FROM PUBLIC;
GRANT USAGE, CREATE ON SCHEMA public TO panels_platform_owner;
GRANT USAGE ON SCHEMA public TO panels_platform_app;
-- Privilegios por defecto para tablas/secuencias que cree panels_platform_owner
-- (vía Liquibase) a partir de ahora: panels_platform_app solo puede hacer DML,
-- nunca DDL.
ALTER DEFAULT PRIVILEGES FOR ROLE panels_platform_owner IN SCHEMA public
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO panels_platform_app;
ALTER DEFAULT PRIVILEGES FOR ROLE panels_platform_owner IN SCHEMA public
GRANT USAGE, SELECT ON SEQUENCES TO panels_platform_app;
-- Objetos que ya existan (si esto corre después de la primera migración de Liquibase)
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO panels_platform_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO panels_platform_app;

View file

@ -0,0 +1,54 @@
-- PANELS · Postgres · Fase 0 (esquemas y privilegios dentro de panels_product)
--
-- Correr conectado A LA BASE panels_product, como superuser/admin:
-- psql "$SUPERUSER_URL/panels_product" -f 04-product-database.sql
--
-- Crea los esquemas iam/core, cada uno con su propio dueño (_owner, usado
-- por Liquibase) y su propio rol de runtime (_app, solo DML). Se revoca
-- explícitamente cualquier acceso cruzado entre iam y core: son dos
-- módulos del monolito modular que NO deben poder leerse entre sí por SQL.
REVOKE ALL ON SCHEMA public FROM PUBLIC;
CREATE SCHEMA IF NOT EXISTS iam AUTHORIZATION panels_iam_owner;
CREATE SCHEMA IF NOT EXISTS core AUTHORIZATION panels_core_owner;
-- citext (comparación case-insensitive real para curp/rfc, reemplaza el
-- COLLATE NOCASE de SQLite) se instala DENTRO del esquema core (no en
-- public, que queda sin USAGE para nadie salvo el propio superuser) --
-- así el tipo es resoluble por panels_core_owner/panels_core_app con su
-- search_path=core, sin tener que abrirles el esquema public.
CREATE EXTENSION IF NOT EXISTS citext SCHEMA core;
-- ===== iam =====
GRANT USAGE ON SCHEMA iam TO panels_iam_app;
ALTER DEFAULT PRIVILEGES FOR ROLE panels_iam_owner IN SCHEMA iam
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO panels_iam_app;
ALTER DEFAULT PRIVILEGES FOR ROLE panels_iam_owner IN SCHEMA iam
GRANT USAGE, SELECT ON SEQUENCES TO panels_iam_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA iam TO panels_iam_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA iam TO panels_iam_app;
ALTER ROLE panels_iam_owner IN DATABASE panels_product SET search_path = iam;
ALTER ROLE panels_iam_app IN DATABASE panels_product SET search_path = iam;
-- ===== core =====
GRANT USAGE ON SCHEMA core TO panels_core_app;
ALTER DEFAULT PRIVILEGES FOR ROLE panels_core_owner IN SCHEMA core
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO panels_core_app;
ALTER DEFAULT PRIVILEGES FOR ROLE panels_core_owner IN SCHEMA core
GRANT USAGE, SELECT ON SEQUENCES TO panels_core_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA core TO panels_core_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA core TO panels_core_app;
ALTER ROLE panels_core_owner IN DATABASE panels_product SET search_path = core;
ALTER ROLE panels_core_app IN DATABASE panels_product SET search_path = core;
-- ===== Aislamiento cruzado explícito (defensa en profundidad) =====
-- Nadie del lado "core" puede tocar "iam" y viceversa, ni por accidente.
REVOKE ALL ON SCHEMA iam FROM panels_core_app, panels_core_owner, PUBLIC;
REVOKE ALL ON SCHEMA core FROM panels_iam_app, panels_iam_owner, PUBLIC;

64
db/provision/05-redis-acl.sh Executable file
View file

@ -0,0 +1,64 @@
#!/usr/bin/env bash
# PANELS · Redis · Fase 0 (usuarios ACL por módulo)
#
# Crea dos usuarios ACL (Redis 6+) con acceso restringido por patrón de
# llave: panels_iam_redis (~iam:*) y panels_core_redis (~core:*). Un
# prefijo de llave por sí solo NO aísla nada — cualquier cliente con la
# misma contraseña puede escanear todo el keyspace; el aislamiento real
# lo da el ACL server-side, no la convención de nombres.
#
# Uso:
# REDIS_ADMIN_URL="redis://:admin-password@host:6379" \
# IAM_REDIS_PASSWORD="$(openssl rand -base64 32)" \
# CORE_REDIS_PASSWORD="$(openssl rand -base64 32)" \
# ./05-redis-acl.sh
set -euo pipefail
: "${REDIS_ADMIN_URL:?Define REDIS_ADMIN_URL (redis://[:password@]host:port)}"
: "${IAM_REDIS_PASSWORD:?Define IAM_REDIS_PASSWORD}"
: "${CORE_REDIS_PASSWORD:?Define CORE_REDIS_PASSWORD}"
# Categorías necesarias para sesiones (hash/string + TTL) y cache
# (string/hash/set): keyspace cubre DEL/EXPIRE/EXISTS/TTL/SCAN (el patrón de
# llave sigue acotando el alcance real). Sin @scripting (no se usa Lua/EVAL,
# menor superficie de ataque) ni @admin/@dangerous.
ACL_COMMANDS="-@all +@read +@write +@keyspace +@string +@hash +@set +@sortedset +@connection"
# Importante: "reset" debe ir PRIMERO (deja al usuario en blanco); si va
# después de "on >password" borra la contraseña que se acaba de fijar.
echo "Creando usuario panels_iam_redis (~iam:*)..."
redis-cli -u "$REDIS_ADMIN_URL" ACL SETUSER panels_iam_redis \
reset \
on ">${IAM_REDIS_PASSWORD}" \
'~iam:*' '&iam:*' \
$ACL_COMMANDS
echo "Creando usuario panels_core_redis (~core:*)..."
redis-cli -u "$REDIS_ADMIN_URL" ACL SETUSER panels_core_redis \
reset \
on ">${CORE_REDIS_PASSWORD}" \
'~core:*' '&core:*' \
$ACL_COMMANDS
redis-cli -u "$REDIS_ADMIN_URL" ACL SAVE >/dev/null 2>&1 || {
echo "Aviso: ACL SAVE falló (normal si no hay aclfile configurado; revisar persistencia de ACLs por separado)."
}
HOST_PORT=$(echo "$REDIS_ADMIN_URL" | sed -E 's#^redis://##; s#^[^@]*@##')
echo "Verificando aislamiento (se espera NOPERM al cruzar de modulo)..."
IAM_PROBE=$(redis-cli -u "redis://panels_iam_redis:${IAM_REDIS_PASSWORD}@${HOST_PORT}" GET core:probe 2>&1 || true)
if echo "$IAM_PROBE" | grep -qi "NOPERM"; then
echo "OK: panels_iam_redis no puede leer llaves core:*"
else
echo "ADVERTENCIA: no se confirmó NOPERM al probar panels_iam_redis -> core:*. Salida: $IAM_PROBE"
fi
CORE_PROBE=$(redis-cli -u "redis://panels_core_redis:${CORE_REDIS_PASSWORD}@${HOST_PORT}" GET iam:probe 2>&1 || true)
if echo "$CORE_PROBE" | grep -qi "NOPERM"; then
echo "OK: panels_core_redis no puede leer llaves iam:*"
else
echo "ADVERTENCIA: no se confirmó NOPERM al probar panels_core_redis -> iam:*. Salida: $CORE_PROBE"
fi
echo "Listo. Guarda IAM_REDIS_PASSWORD/CORE_REDIS_PASSWORD como secrets (REDIS_URL_IAM/REDIS_URL_CORE)."

80
db/provision/README.md Normal file
View file

@ -0,0 +1,80 @@
# Aprovisionamiento de infraestructura (Fase 0)
Scripts para dejar Postgres y Redis listos, en cualquier ambiente (dev local,
staging o producción en Coolify), antes de correr Liquibase o levantar la API.
Ver el plan de migración para el diseño completo: dos bases de datos
(`panels_platform`, `panels_product`) y, dentro de `panels_product`, dos
esquemas (`iam`, `core`).
## Orden de ejecución (Postgres)
Contra el servidor Postgres del ambiente (uno por dev/staging/producción),
como usuario superusuario/admin:
```bash
SUPERUSER_URL="postgresql://postgres:adminpass@HOST:5432/postgres"
# 1. Roles cluster-wide (genera una clave distinta por rol y por ambiente:
# openssl rand -hex 24). Pasar los valores SIN comillas propias -- el
# script usa :'var' internamente para escaparlos como literal SQL.
psql "$SUPERUSER_URL" \
-v platform_owner_pw="..." -v platform_app_pw="..." \
-v iam_owner_pw="..." -v iam_app_pw="..." \
-v core_owner_pw="..." -v core_app_pw="..." \
-f 01-roles.sql
# 2. Bases de datos (panels_platform, panels_product)
psql "$SUPERUSER_URL" -f 02-databases.sql
# 3. Privilegios dentro de panels_platform
psql "${SUPERUSER_URL%/postgres}/panels_platform" -f 03-platform-database.sql
# 4. Esquemas + privilegios dentro de panels_product
psql "${SUPERUSER_URL%/postgres}/panels_product" -f 04-product-database.sql
```
Las conexiones que usará la API (secrets por ambiente):
| Secret | Apunta a | Usuario |
|---|---|---|
| `DATABASE_URL_PLATFORM` | `panels_platform` | `panels_platform_app` |
| `DATABASE_URL_IAM` | `panels_product` (search_path=iam) | `panels_iam_app` |
| `DATABASE_URL_CORE` | `panels_product` (search_path=core) | `panels_core_app` |
Liquibase (paso de deploy, no en runtime) usa los roles `_owner`:
`DATABASE_URL_PLATFORM_OWNER`, `DATABASE_URL_IAM_OWNER`, `DATABASE_URL_CORE_OWNER`.
## Redis
```bash
REDIS_ADMIN_URL="redis://:adminpass@HOST:6379" \
IAM_REDIS_PASSWORD="$(openssl rand -base64 32)" \
CORE_REDIS_PASSWORD="$(openssl rand -base64 32)" \
./05-redis-acl.sh
```
Secrets resultantes: `REDIS_URL_IAM` (`redis://panels_iam_redis:<pw>@HOST:6379`),
`REDIS_URL_CORE` (`redis://panels_core_redis:<pw>@HOST:6379`).
El script verifica automáticamente que cruzar de módulo devuelva `NOPERM`
(ver "Riesgos y mitigaciones" del plan: un prefijo de llave sin ACL detrás
no aísla nada).
## Qué falta hacer manualmente en Coolify (staging/producción)
Estos scripts asumen que ya existe un servidor Postgres y un servidor Redis
accesibles (el recurso gestionado de Coolify por ambiente). El agente no
tiene acceso a la cuenta de Coolify del usuario, así que:
1. Crear el recurso Postgres gestionado en Coolify para el ambiente.
2. Crear el recurso Redis gestionado en Coolify para el ambiente.
3. Correr estos scripts contra ambos usando las credenciales admin que da Coolify.
4. Cargar los secrets resultantes (`DATABASE_URL_*`, `REDIS_URL_*`) en la
configuración del servicio `api` de ese ambiente.
## Desarrollo local
`db/provision/dev-local.sh` reproduce estos mismos pasos contra un Postgres
y Redis instalados en la máquina de desarrollo (sin Coolify), generando
`.env.dev-local` con las URLs resultantes — útil para correr la API y los
tests sin depender de infraestructura externa.

62
db/provision/dev-local.sh Executable file
View file

@ -0,0 +1,62 @@
#!/usr/bin/env bash
# PANELS · Fase 0 · aprovisiona Postgres + Redis LOCALES (dev), reproduciendo
# la misma topología de roles/esquemas/ACLs que se usará en Coolify.
#
# Requiere: un Postgres corriendo en localhost:5432 accesible como `postgres`
# vía socket unix (auth peer, el default de un `apt install postgresql`), y
# un Redis en localhost:6379.
#
# Uso: ./db/provision/dev-local.sh
set -euo pipefail
cd "$(dirname "$0")"
PG_SUPERUSER_CMD="sudo -u postgres psql -v ON_ERROR_STOP=1"
PGHOST=127.0.0.1
PGPORT=5432
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
gen_pw() { openssl rand -hex 24; }
PLATFORM_OWNER_PW=$(gen_pw); PLATFORM_APP_PW=$(gen_pw)
IAM_OWNER_PW=$(gen_pw); IAM_APP_PW=$(gen_pw)
CORE_OWNER_PW=$(gen_pw); CORE_APP_PW=$(gen_pw)
IAM_REDIS_PW=$(gen_pw); CORE_REDIS_PW=$(gen_pw)
echo "== 1/5: roles =="
$PG_SUPERUSER_CMD \
-v platform_owner_pw="${PLATFORM_OWNER_PW}" -v platform_app_pw="${PLATFORM_APP_PW}" \
-v iam_owner_pw="${IAM_OWNER_PW}" -v iam_app_pw="${IAM_APP_PW}" \
-v core_owner_pw="${CORE_OWNER_PW}" -v core_app_pw="${CORE_APP_PW}" \
-f 01-roles.sql
echo "== 2/5: bases de datos =="
$PG_SUPERUSER_CMD -f 02-databases.sql
echo "== 3/5: privilegios panels_platform =="
$PG_SUPERUSER_CMD -d panels_platform -f 03-platform-database.sql
echo "== 4/5: esquemas + privilegios panels_product =="
$PG_SUPERUSER_CMD -d panels_product -f 04-product-database.sql
echo "== 5/5: Redis ACLs =="
REDIS_ADMIN_URL="redis://${REDIS_HOST}:${REDIS_PORT}" \
IAM_REDIS_PASSWORD="$IAM_REDIS_PW" \
CORE_REDIS_PASSWORD="$CORE_REDIS_PW" \
./05-redis-acl.sh
ENV_FILE="../../.env.dev-local"
cat > "$ENV_FILE" <<EOF
# Generado por db/provision/dev-local.sh — solo para desarrollo local. No commitear.
DATABASE_URL_PLATFORM=postgresql://panels_platform_app:${PLATFORM_APP_PW}@${PGHOST}:${PGPORT}/panels_platform
DATABASE_URL_PLATFORM_OWNER=postgresql://panels_platform_owner:${PLATFORM_OWNER_PW}@${PGHOST}:${PGPORT}/panels_platform
DATABASE_URL_IAM=postgresql://panels_iam_app:${IAM_APP_PW}@${PGHOST}:${PGPORT}/panels_product
DATABASE_URL_IAM_OWNER=postgresql://panels_iam_owner:${IAM_OWNER_PW}@${PGHOST}:${PGPORT}/panels_product
DATABASE_URL_CORE=postgresql://panels_core_app:${CORE_APP_PW}@${PGHOST}:${PGPORT}/panels_product
DATABASE_URL_CORE_OWNER=postgresql://panels_core_owner:${CORE_OWNER_PW}@${PGHOST}:${PGPORT}/panels_product
REDIS_URL_IAM=redis://panels_iam_redis:${IAM_REDIS_PW}@${REDIS_HOST}:${REDIS_PORT}
REDIS_URL_CORE=redis://panels_core_redis:${CORE_REDIS_PW}@${REDIS_HOST}:${REDIS_PORT}
EOF
echo ""
echo "Listo. Credenciales de desarrollo local escritas en $(cd "$(dirname "$ENV_FILE")" && pwd)/$(basename "$ENV_FILE")"

View file

@ -0,0 +1,67 @@
#!/usr/bin/env bash
# PANELS · Fase 0 · verifica que el aislamiento entre módulos realmente
# se cumple (no solo que las credenciales existan). Pensado para correr
# después de 01-04 (Postgres) y 05 (Redis), en cualquier ambiente.
#
# Uso: source el .env con DATABASE_URL_*/REDIS_URL_* y correr este script.
set -uo pipefail
cd "$(dirname "$0")"
fail=0
check() {
local desc="$1"; local expect_fail="$2"; shift 2
if "$@" >/tmp/verify-out.$$ 2>&1; then
ok=1
else
ok=0
fi
if [[ "$expect_fail" == "yes" && "$ok" == "0" ]] || [[ "$expect_fail" == "no" && "$ok" == "1" ]]; then
echo "OK - $desc"
else
echo "FAIL - $desc"
cat /tmp/verify-out.$$
fail=1
fi
rm -f /tmp/verify-out.$$
}
: "${DATABASE_URL_PLATFORM:?}"; : "${DATABASE_URL_IAM:?}"; : "${DATABASE_URL_CORE:?}"
: "${REDIS_URL_IAM:?}"; : "${REDIS_URL_CORE:?}"
extract_pw() { echo "$1" | sed -E 's#[a-zA-Z]+://[^:]+:([^@]+)@.*#\1#'; }
PLATFORM_HOST=$(echo "$DATABASE_URL_PLATFORM" | sed -E 's#.*@([^/]+)/.*#\1#')
echo "== Postgres: DML normal funciona =="
check "iam_app puede hacer SELECT en su esquema" no psql "$DATABASE_URL_IAM" -c "SELECT 1;"
check "core_app puede hacer SELECT en su esquema" no psql "$DATABASE_URL_CORE" -c "SELECT 1;"
echo "== Postgres: aislamiento cruzado =="
check "iam_app NO puede leer schema core" yes psql "$DATABASE_URL_IAM" -c "SELECT * FROM core.pg_tables LIMIT 1;"
check "core_app NO puede leer schema iam" yes psql "$DATABASE_URL_CORE" -c "SELECT 1 FROM iam.pg_am LIMIT 1;"
check "iam_app NO puede hacer DDL (solo _owner puede)" yes psql "$DATABASE_URL_IAM" -c "CREATE TABLE iam.probe_$$(id int);"
PLATFORM_APP_PW=$(extract_pw "$DATABASE_URL_PLATFORM")
IAM_APP_PW=$(extract_pw "$DATABASE_URL_IAM")
check "platform_app NO puede CONNECT a panels_product" yes \
psql "postgresql://panels_platform_app:${PLATFORM_APP_PW}@${PLATFORM_HOST%/*}/panels_product" -c "SELECT 1;"
check "iam_app NO puede CONNECT a panels_platform" yes \
psql "postgresql://panels_iam_app:${IAM_APP_PW}@${PLATFORM_HOST%/*}/panels_platform" -c "SELECT 1;"
echo "== Redis: ACL por módulo =="
IAM_REDIS_HOST=$(echo "$REDIS_URL_IAM" | sed -E 's#redis://[^@]+@##')
IAM_REDIS_PW=$(extract_pw "$REDIS_URL_IAM")
CORE_REDIS_PW=$(extract_pw "$REDIS_URL_CORE")
check "panels_iam_redis puede escribir iam:*" no \
redis-cli -u "redis://panels_iam_redis:${IAM_REDIS_PW}@${IAM_REDIS_HOST}" SET iam:verify:probe 1
check "panels_iam_redis NO puede leer core:* (NOPERM)" no \
bash -c "redis-cli -u 'redis://panels_iam_redis:${IAM_REDIS_PW}@${IAM_REDIS_HOST}' GET core:probe 2>&1 | grep -q NOPERM"
check "panels_core_redis NO puede leer iam:* (NOPERM)" no \
bash -c "redis-cli -u 'redis://panels_core_redis:${CORE_REDIS_PW}@${IAM_REDIS_HOST}' GET iam:probe 2>&1 | grep -q NOPERM"
echo ""
if [[ "$fail" == "0" ]]; then
echo "Todas las verificaciones de aislamiento pasaron."
else
echo "Hay verificaciones fallidas -- revisar antes de continuar."
exit 1
fi

View file

@ -1,38 +1,72 @@
#!/usr/bin/env bash
# PANELS · aplica los changelogs de Liquibase contra Postgres.
#
# Requiere las credenciales del rol "_owner" de cada módulo (nunca el rol
# "_app" -- ese es solo para runtime, sin privilegios de DDL). Se leen de
# variables de entorno con el mismo nombre que usan los secrets de la app:
# DATABASE_URL_PLATFORM_OWNER (jdbc: panels_platform)
# DATABASE_URL_IAM_OWNER (jdbc: panels_product, schema iam)
# DATABASE_URL_CORE_OWNER (jdbc: panels_product, schema core)
#
# Formato esperado: postgresql://usuario:password@host:puerto/basededatos
#
# Uso:
# set -a && source .env.dev-local && set +a && ./db/update.sh all
# ./db/update.sh core --context-filter=dev
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
"$ROOT/db/bootstrap-tools.sh"
LIQUIBASE="$ROOT/db/tools/liquibase/liquibase"
SQLITE_JDBC="$ROOT/db/tools/sqlite-jdbc.jar"
mkdir -p "$ROOT/data"
PG_JDBC="$ROOT/db/tools/postgresql-jdbc.jar"
TARGET="${1:-all}"
shift || true
EXTRA_ARGS=("$@")
# postgresql://user:pass@host:port/db -> jdbc:postgresql://host:port/db + user/pass
parse_url() {
local url="$1"
local rest="${url#postgresql://}"
local userinfo="${rest%%@*}"
local hostpart="${rest#*@}"
PARSED_USER="${userinfo%%:*}"
PARSED_PASS="${userinfo#*:}"
PARSED_JDBC="jdbc:postgresql://${hostpart}"
}
run_one() {
local name="$1"
local dir="$ROOT/db/$name"
local db="$ROOT/data/${name}.db"
echo "==> Liquibase update: $name ($db)"
local var="DATABASE_URL_${name^^}_OWNER"
local url="${!var:-}"
if [[ -z "$url" ]]; then
echo "Falta la variable de entorno $var (credenciales del rol _owner de $name)" >&2
exit 1
fi
parse_url "$url"
echo "==> Liquibase update: $name ($PARSED_JDBC)"
(
cd "$dir"
cd "$ROOT/db/$name"
"$LIQUIBASE" \
--defaultsFile=liquibase.properties \
--classpath="$SQLITE_JDBC" \
--url="jdbc:sqlite:$db" \
update
--classpath="$PG_JDBC" \
--url="$PARSED_JDBC" \
--username="$PARSED_USER" \
--password="$PARSED_PASS" \
update "${EXTRA_ARGS[@]}"
)
}
case "$TARGET" in
app) run_one app ;;
platform) run_one platform ;;
iam) run_one iam ;;
core) run_one core ;;
all)
run_one app
run_one platform
run_one iam
run_one core
;;
*)
echo "Usage: $0 [all|app|platform]" >&2
echo "Usage: $0 [all|platform|iam|core] [-- liquibase-args...]" >&2
exit 1
;;
esac