From b87b0205a89f34510279913f9193e03986f41f11 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 2 Sep 2026 20:00:49 +0000 Subject: [PATCH] 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 --- db/README.md | 77 ++- db/app/changelog-master.xml | 13 - db/app/changesets/001-baseline.sql | 254 --------- db/app/changesets/002-tenant-id.sql | 43 -- .../changesets/003-must-change-password.sql | 11 - db/app/changesets/004-document-catalog.sql | 40 -- db/app/changesets/005-payroll-week.sql | 164 ------ db/app/liquibase.properties | 4 - db/bootstrap-tools.sh | 8 +- db/core/changelog-master.xml | 16 + db/core/changesets/001-baseline.sql | 482 ++++++++++++++++++ db/core/changesets/002-tenant-settings.sql | 17 + db/core/changesets/003-seed-catalogs.sql | 73 +++ db/core/changesets/004-seed-dev-data.sql | 31 ++ db/core/changesets/005-rls.sql | 71 +++ db/core/liquibase.properties | 9 + db/iam/changelog-master.xml | 12 + db/iam/changesets/001-baseline.sql | 92 ++++ db/iam/changesets/002-rls.sql | 28 + db/iam/liquibase.properties | 11 + db/platform/changelog-master.xml | 18 +- db/platform/changesets/001-baseline.sql | 66 +++ db/platform/changesets/001-init.sql | 20 - .../changesets/002-seed-arctec-tenant.sql | 7 - db/platform/changesets/003-tenant-license.sql | 16 - .../changesets/004-tenant-status-expand.sql | 30 -- db/platform/changesets/005-contact-access.sql | 16 - .../changesets/006-tenant-code-date.sql | 7 - db/platform/changesets/007-smtp-settings.sql | 18 - db/platform/liquibase.properties | 12 +- db/provision/01-roles.sql | 51 ++ db/provision/02-databases.sql | 33 ++ db/provision/03-platform-database.sql | 25 + db/provision/04-product-database.sql | 54 ++ db/provision/05-redis-acl.sh | 64 +++ db/provision/README.md | 80 +++ db/provision/dev-local.sh | 62 +++ db/provision/verify-isolation.sh | 67 +++ db/update.sh | 60 ++- 39 files changed, 1471 insertions(+), 691 deletions(-) delete mode 100644 db/app/changelog-master.xml delete mode 100644 db/app/changesets/001-baseline.sql delete mode 100644 db/app/changesets/002-tenant-id.sql delete mode 100644 db/app/changesets/003-must-change-password.sql delete mode 100644 db/app/changesets/004-document-catalog.sql delete mode 100644 db/app/changesets/005-payroll-week.sql delete mode 100644 db/app/liquibase.properties create mode 100644 db/core/changelog-master.xml create mode 100644 db/core/changesets/001-baseline.sql create mode 100644 db/core/changesets/002-tenant-settings.sql create mode 100644 db/core/changesets/003-seed-catalogs.sql create mode 100644 db/core/changesets/004-seed-dev-data.sql create mode 100644 db/core/changesets/005-rls.sql create mode 100644 db/core/liquibase.properties create mode 100644 db/iam/changelog-master.xml create mode 100644 db/iam/changesets/001-baseline.sql create mode 100644 db/iam/changesets/002-rls.sql create mode 100644 db/iam/liquibase.properties create mode 100644 db/platform/changesets/001-baseline.sql delete mode 100644 db/platform/changesets/001-init.sql delete mode 100644 db/platform/changesets/002-seed-arctec-tenant.sql delete mode 100644 db/platform/changesets/003-tenant-license.sql delete mode 100644 db/platform/changesets/004-tenant-status-expand.sql delete mode 100644 db/platform/changesets/005-contact-access.sql delete mode 100644 db/platform/changesets/006-tenant-code-date.sql delete mode 100644 db/platform/changesets/007-smtp-settings.sql create mode 100644 db/provision/01-roles.sql create mode 100644 db/provision/02-databases.sql create mode 100644 db/provision/03-platform-database.sql create mode 100644 db/provision/04-product-database.sql create mode 100755 db/provision/05-redis-acl.sh create mode 100644 db/provision/README.md create mode 100755 db/provision/dev-local.sh create mode 100755 db/provision/verify-isolation.sh diff --git a/db/README.md b/db/README.md index 483ee1a..b2eb715 100644 --- a/db/README.md +++ b/db/README.md @@ -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. diff --git a/db/app/changelog-master.xml b/db/app/changelog-master.xml deleted file mode 100644 index 0982bef..0000000 --- a/db/app/changelog-master.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - diff --git a/db/app/changesets/001-baseline.sql b/db/app/changesets/001-baseline.sql deleted file mode 100644 index b1a4004..0000000 --- a/db/app/changesets/001-baseline.sql +++ /dev/null @@ -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); diff --git a/db/app/changesets/002-tenant-id.sql b/db/app/changesets/002-tenant-id.sql deleted file mode 100644 index 9ad4792..0000000 --- a/db/app/changesets/002-tenant-id.sql +++ /dev/null @@ -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); diff --git a/db/app/changesets/003-must-change-password.sql b/db/app/changesets/003-must-change-password.sql deleted file mode 100644 index 3f9c238..0000000 --- a/db/app/changesets/003-must-change-password.sql +++ /dev/null @@ -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 ''; diff --git a/db/app/changesets/004-document-catalog.sql b/db/app/changesets/004-document-catalog.sql deleted file mode 100644 index f2a18fe..0000000 --- a/db/app/changesets/004-document-catalog.sql +++ /dev/null @@ -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')) -); diff --git a/db/app/changesets/005-payroll-week.sql b/db/app/changesets/005-payroll-week.sql deleted file mode 100644 index bee8761..0000000 --- a/db/app/changesets/005-payroll-week.sql +++ /dev/null @@ -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; diff --git a/db/app/liquibase.properties b/db/app/liquibase.properties deleted file mode 100644 index aca1901..0000000 --- a/db/app/liquibase.properties +++ /dev/null @@ -1,4 +0,0 @@ -changeLogFile=changelog-master.xml -driver=org.sqlite.JDBC -url=jdbc:sqlite:../../data/app.db -classpath=../tools/sqlite-jdbc.jar diff --git a/db/bootstrap-tools.sh b/db/bootstrap-tools.sh index cd866d2..0acc2cf 100755 --- a/db/bootstrap-tools.sh +++ b/db/bootstrap-tools.sh @@ -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." diff --git a/db/core/changelog-master.xml b/db/core/changelog-master.xml new file mode 100644 index 0000000..0a661ea --- /dev/null +++ b/db/core/changelog-master.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + diff --git a/db/core/changesets/001-baseline.sql b/db/core/changesets/001-baseline.sql new file mode 100644 index 0000000..309a4a2 --- /dev/null +++ b/db/core/changesets/001-baseline.sql @@ -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 +); diff --git a/db/core/changesets/002-tenant-settings.sql b/db/core/changesets/002-tenant-settings.sql new file mode 100644 index 0000000..a590e68 --- /dev/null +++ b/db/core/changesets/002-tenant-settings.sql @@ -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() +); diff --git a/db/core/changesets/003-seed-catalogs.sql b/db/core/changesets/003-seed-catalogs.sql new file mode 100644 index 0000000..cad74f5 --- /dev/null +++ b/db/core/changesets/003-seed-catalogs.sql @@ -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; diff --git a/db/core/changesets/004-seed-dev-data.sql b/db/core/changesets/004-seed-dev-data.sql new file mode 100644 index 0000000..4a5025e --- /dev/null +++ b/db/core/changesets/004-seed-dev-data.sql @@ -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'); diff --git a/db/core/changesets/005-rls.sql b/db/core/changesets/005-rls.sql new file mode 100644 index 0000000..66b52c4 --- /dev/null +++ b/db/core/changesets/005-rls.sql @@ -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); diff --git a/db/core/liquibase.properties b/db/core/liquibase.properties new file mode 100644 index 0000000..1a3919f --- /dev/null +++ b/db/core/liquibase.properties @@ -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 diff --git a/db/iam/changelog-master.xml b/db/iam/changelog-master.xml new file mode 100644 index 0000000..15ba648 --- /dev/null +++ b/db/iam/changelog-master.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/db/iam/changesets/001-baseline.sql b/db/iam/changesets/001-baseline.sql new file mode 100644 index 0000000..0160f08 --- /dev/null +++ b/db/iam/changesets/001-baseline.sql @@ -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. diff --git a/db/iam/changesets/002-rls.sql b/db/iam/changesets/002-rls.sql new file mode 100644 index 0000000..4403dfb --- /dev/null +++ b/db/iam/changesets/002-rls.sql @@ -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 = ''. 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); diff --git a/db/iam/liquibase.properties b/db/iam/liquibase.properties new file mode 100644 index 0000000..a51e4ec --- /dev/null +++ b/db/iam/liquibase.properties @@ -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 diff --git a/db/platform/changelog-master.xml b/db/platform/changelog-master.xml index 050c58d..68f0ec2 100644 --- a/db/platform/changelog-master.xml +++ b/db/platform/changelog-master.xml @@ -1,15 +1,11 @@ + 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"> + + + - - - - - - - diff --git a/db/platform/changesets/001-baseline.sql b/db/platform/changesets/001-baseline.sql new file mode 100644 index 0000000..5937993 --- /dev/null +++ b/db/platform/changesets/001-baseline.sql @@ -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)); diff --git a/db/platform/changesets/001-init.sql b/db/platform/changesets/001-init.sql deleted file mode 100644 index a4621db..0000000 --- a/db/platform/changesets/001-init.sql +++ /dev/null @@ -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')) -); diff --git a/db/platform/changesets/002-seed-arctec-tenant.sql b/db/platform/changesets/002-seed-arctec-tenant.sql deleted file mode 100644 index 1b661fd..0000000 --- a/db/platform/changesets/002-seed-arctec-tenant.sql +++ /dev/null @@ -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'); diff --git a/db/platform/changesets/003-tenant-license.sql b/db/platform/changesets/003-tenant-license.sql deleted file mode 100644 index 56c093d..0000000 --- a/db/platform/changesets/003-tenant-license.sql +++ /dev/null @@ -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 ''; diff --git a/db/platform/changesets/004-tenant-status-expand.sql b/db/platform/changesets/004-tenant-status-expand.sql deleted file mode 100644 index d37e667..0000000 --- a/db/platform/changesets/004-tenant-status-expand.sql +++ /dev/null @@ -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; diff --git a/db/platform/changesets/005-contact-access.sql b/db/platform/changesets/005-contact-access.sql deleted file mode 100644 index fdd9f14..0000000 --- a/db/platform/changesets/005-contact-access.sql +++ /dev/null @@ -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; diff --git a/db/platform/changesets/006-tenant-code-date.sql b/db/platform/changesets/006-tenant-code-date.sql deleted file mode 100644 index 3d97866..0000000 --- a/db/platform/changesets/006-tenant-code-date.sql +++ /dev/null @@ -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'; diff --git a/db/platform/changesets/007-smtp-settings.sql b/db/platform/changesets/007-smtp-settings.sql deleted file mode 100644 index 9d88021..0000000 --- a/db/platform/changesets/007-smtp-settings.sql +++ /dev/null @@ -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); diff --git a/db/platform/liquibase.properties b/db/platform/liquibase.properties index 908e48b..f355236 100644 --- a/db/platform/liquibase.properties +++ b/db/platform/liquibase.properties @@ -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 diff --git a/db/provision/01-roles.sql b/db/provision/01-roles.sql new file mode 100644 index 0000000..c5fe0e1 --- /dev/null +++ b/db/provision/01-roles.sql @@ -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'; diff --git a/db/provision/02-databases.sql b/db/provision/02-databases.sql new file mode 100644 index 0000000..c7c584d --- /dev/null +++ b/db/provision/02-databases.sql @@ -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; diff --git a/db/provision/03-platform-database.sql b/db/provision/03-platform-database.sql new file mode 100644 index 0000000..a626a1a --- /dev/null +++ b/db/provision/03-platform-database.sql @@ -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; diff --git a/db/provision/04-product-database.sql b/db/provision/04-product-database.sql new file mode 100644 index 0000000..fedcc86 --- /dev/null +++ b/db/provision/04-product-database.sql @@ -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; diff --git a/db/provision/05-redis-acl.sh b/db/provision/05-redis-acl.sh new file mode 100755 index 0000000..1576449 --- /dev/null +++ b/db/provision/05-redis-acl.sh @@ -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)." diff --git a/db/provision/README.md b/db/provision/README.md new file mode 100644 index 0000000..5c3a909 --- /dev/null +++ b/db/provision/README.md @@ -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:@HOST:6379`), +`REDIS_URL_CORE` (`redis://panels_core_redis:@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. diff --git a/db/provision/dev-local.sh b/db/provision/dev-local.sh new file mode 100755 index 0000000..0e5e88e --- /dev/null +++ b/db/provision/dev-local.sh @@ -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" </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 diff --git a/db/update.sh b/db/update.sh index d8c22d7..846b07a 100755 --- a/db/update.sh +++ b/db/update.sh @@ -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