diff --git a/.github/workflows/database.yml b/.github/workflows/database.yml index bce50f6..0b478e1 100644 --- a/.github/workflows/database.yml +++ b/.github/workflows/database.yml @@ -9,17 +9,23 @@ name: database # # También se dispara cuando cambia el mapeo JPA, porque es justo ahí donde # el esquema escrito a mano y las entidades pueden divergir (ADR-0005). +# +# El esquema son las migraciones de Flyway (ADR-0006). Aquí se aplican con +# psql en orden de versión; que Flyway mismo las ejecute lo comprueban las +# pruebas de integración del workflow de build. on: push: branches: [main] paths: - 'docs/database/**' + - 'src/main/resources/db/migration/**' - 'src/main/java/**/persistence/**' - '.github/workflows/database.yml' pull_request: paths: - 'docs/database/**' + - 'src/main/resources/db/migration/**' - 'src/main/java/**/persistence/**' - '.github/workflows/database.yml' workflow_dispatch: @@ -54,6 +60,7 @@ jobs: env: PGPASSWORD: postgres PSQL: psql -h localhost -U postgres -d reservas + MIGRACIONES: src/main/resources/db/migration steps: - uses: actions/checkout@v4 @@ -63,8 +70,12 @@ jobs: command -v psql || sudo apt-get update && sudo apt-get install -y postgresql-client psql --version - - name: El DDL ejecuta contra una base vacía - run: $PSQL -v ON_ERROR_STOP=1 -q -f docs/database/schema.sql + - name: Las migraciones ejecutan contra una base vacía + run: | + for f in $(ls $MIGRACIONES/V*__*.sql | sort -V); do + echo "aplicando $f" + $PSQL -v ON_ERROR_STOP=1 -q -f "$f" + done - name: Se crearon las 19 tablas run: | @@ -72,8 +83,13 @@ jobs: echo "tablas creadas: $n" test "$n" -eq 19 - - name: El DDL es idempotente - run: $PSQL -v ON_ERROR_STOP=1 -q -f docs/database/schema.sql + # V1 y V2 se vuelven a aplicar sobre las bases creadas antes de Flyway + # (línea base 0), así que tienen que seguir siendo idempotentes. Las + # migraciones siguientes corren una sola vez y no necesitan serlo. + - name: V1 y V2 son idempotentes + run: | + $PSQL -v ON_ERROR_STOP=1 -q -f $MIGRACIONES/V1__esquema_inicial.sql + $PSQL -v ON_ERROR_STOP=1 -q -f $MIGRACIONES/V2__rls.sql - name: Los datos de prueba cargan run: $PSQL -v ON_ERROR_STOP=1 -q -f docs/database/seed.sql @@ -101,9 +117,10 @@ jobs: test "$rechazos" -eq 18 test "$coladas" -eq 0 + # Ninguna tabla de public sin RLS, sean cuantas sean: una migración + # nueva que olvide habilitarlo deja la tabla expuesta por PostgREST. - name: La seguridad de fila queda habilitada run: | - $PSQL -v ON_ERROR_STOP=1 -q -f docs/database/rls.sql - n=$($PSQL -tAc "select count(*) from pg_class where relnamespace = 'public'::regnamespace and relkind = 'r' and relrowsecurity") - echo "tablas con RLS: $n" - test "$n" -eq 19 + sin_rls=$($PSQL -tAc "select string_agg(relname, ', ') from pg_class where relnamespace = 'public'::regnamespace and relkind = 'r' and not relrowsecurity") + echo "tablas sin RLS: ${sin_rls:-ninguna}" + test -z "$sin_rls" diff --git a/README.md b/README.md index 4319f9d..f9848bf 100644 --- a/README.md +++ b/README.md @@ -21,12 +21,13 @@ contraseña). MFA de administradores diferido a Sprint 2. | ADR-0003 Autenticación JWT/JWKS + bloqueo propio | `docs/adr/ADR-0003-*.md` | | ADR-0004 API versionada, ProblemDetail, traceId | `docs/adr/ADR-0004-*.md` | | ADR-0005 Modelo físico escrito a mano + CI de BD | `docs/adr/ADR-0005-*.md` | +| ADR-0006 Migraciones de esquema con Flyway | `docs/adr/ADR-0006-*.md` | | Guía de la carpeta de base de datos | `docs/database/README.md` | | Modelo lógico completo (ER) | `docs/database/modelo-logico.md` | -| Modelo físico (DDL, 19 tablas) | `docs/database/schema.sql` | +| Modelo físico: migraciones de Flyway (V1 = las 19 tablas del Sprint 1) | `src/main/resources/db/migration/` | | Datos de prueba | `docs/database/seed.sql` | | Restricciones de integridad en acción | `docs/database/pruebas-integridad.sql` | -| Seguridad Supabase (RLS) | `docs/database/rls.sql` | +| Seguridad Supabase (RLS) | `src/main/resources/db/migration/V2__rls.sql` | | Consultas clave del negocio | `docs/database/consultas-clave.md` | | Consultas clave, versión ejecutable | `docs/database/consultas-clave.sql` | | Diagrama ER editable | `docs/database/diagrama-er.drawio` | @@ -145,10 +146,13 @@ Linux/macOS: Notas: -- El esquema completo vive en `docs/database/schema.sql` y se aplica con - `psql -f` o desde el SQL Editor de Supabase (ADR-0005). `ddl-auto=update` - sigue creando en local las tablas que tienen entidad JPA. Después - ejecuta `docs/database/rls.sql`. +- El esquema lo crea Flyway al arrancar, con las migraciones de + `src/main/resources/db/migration/` (ADR-0006). Hibernate ya no crea tablas: + solo valida que las entidades cuadren con el esquema. +- La aplicación migra la base a la que apunta `DATABASE_URL`. Si tu `.env` + apunta a Supabase, al arrancar le aplicas las migraciones de tu rama a la + base compartida. Para desarrollar, usa el PostgreSQL de `docker compose up -d db` + (`DATABASE_URL=jdbc:postgresql://localhost:5432/postgres`). - Swagger UI: http://localhost:8080/swagger-ui.html - Health: http://localhost:8080/actuator/health @@ -186,7 +190,8 @@ Errores: `application/problem+json` con `errorCode`, `traceId` y `details`. ## Despliegue en Render Producción: `https://bookingplatform-81wi.onrender.com` -(Web Service Docker desde `main`, perfil `cloud`, `ddl-auto=validate`). +(Web Service Docker desde `main`, perfil `cloud`. Al arrancar, Flyway aplica +las migraciones pendientes a Supabase y Hibernate valida el esquema). ### Crear el servicio diff --git a/docs/adr/ADR-0002-supabase-postgres-auth.md b/docs/adr/ADR-0002-supabase-postgres-auth.md index 14d0b1d..14f9711 100644 --- a/docs/adr/ADR-0002-supabase-postgres-auth.md +++ b/docs/adr/ADR-0002-supabase-postgres-auth.md @@ -1,6 +1,6 @@ # ADR-0002: PostgreSQL administrado en Supabase como base de datos y proveedor de identidad -- **Estado:** Aceptado; la estrategia de esquema la modifica el ADR-0005 +- **Estado:** Aceptado; la estrategia de esquema la modifica el ADR-0005 y la reemplaza el ADR-0006 - **Fecha:** 2026-09-18 - **Prioridad:** Alta - **Decisores:** Equipo de desarrollo — rol Arquitecto de Software y BD diff --git a/docs/adr/ADR-0005-modelo-fisico-escrito-a-mano.md b/docs/adr/ADR-0005-modelo-fisico-escrito-a-mano.md index e7d63d5..d8c0bb8 100644 --- a/docs/adr/ADR-0005-modelo-fisico-escrito-a-mano.md +++ b/docs/adr/ADR-0005-modelo-fisico-escrito-a-mano.md @@ -1,6 +1,6 @@ # ADR-0005: El modelo físico se escribe a mano y se verifica en integración continua -- **Estado:** Aceptado; modifica la estrategia de esquema del ADR-0002 +- **Estado:** Aceptado; modifica la estrategia de esquema del ADR-0002. Reemplazado en ese punto por el ADR-0006: el esquema vive ahora en las migraciones de Flyway - **Fecha:** 2026-09-21 - **Prioridad:** Alta - **Decisores:** Equipo de desarrollo — rol Arquitecto de Software y BD diff --git a/docs/adr/ADR-0006-migraciones-flyway.md b/docs/adr/ADR-0006-migraciones-flyway.md new file mode 100644 index 0000000..61c7f0c --- /dev/null +++ b/docs/adr/ADR-0006-migraciones-flyway.md @@ -0,0 +1,102 @@ +# ADR-0006: El esquema se versiona con migraciones de Flyway + +- **Estado:** Aceptado; reemplaza la estrategia de esquema del ADR-0002 y del ADR-0005 +- **Fecha:** 2026-10-24 +- **Prioridad:** Alta +- **Decisores:** Equipo de desarrollo — rol Arquitecto de Software y BD + +## Contexto + +El ADR-0005 hizo de `docs/database/schema.sql` la fuente del modelo de datos, +escrito a mano y aplicado con `psql` o desde el SQL Editor de Supabase, y dejó +Flyway para el Sprint 2. Esa estrategia tiene tres limitaciones que el Sprint 2 +vuelve urgentes: + +1. **Las pruebas de integración no usaban el esquema real.** El perfil de + pruebas tenía `ddl-auto=create-drop`, así que Hibernate creaba las tablas a + partir de las entidades. Los triggers, las restricciones `exclude` y los + `check` de `schema.sql` no actuaban en ninguna prueba de la aplicación; solo + los comprobaba el workflow de base de datos, por separado. +2. **Un único archivo editado por varias personas.** HU-002, 003, 004, 006 y + 009 cambian el esquema a la vez (agendas, mantenimientos, estados de + servicio, documento del proveedor). Sobre un solo `schema.sql`, esos cambios + chocan, y nada registra qué versión tiene aplicada cada base. +3. **El despliegue dependía de un paso manual.** Había que acordarse de aplicar + el script en Supabase antes de desplegar; si se olvidaba, el `validate` del + perfil `cloud` tumbaba el arranque. + +## Decisión + +Adoptar Flyway, integrado en Spring Boot, como la única vía de cambio del +esquema. + +1. **Las migraciones son la fuente del modelo físico** y viven en + `src/main/resources/db/migration/`. `V1__esquema_inicial.sql` es el + `schema.sql` del Sprint 1, con el mismo DDL y el encabezado al día. + `V2__rls.sql` es el `rls.sql`, también sin cambios en lo que ejecuta. Una + migración aplicada no se edita nunca: los cambios van en una versión nueva. +2. **Hibernate solo valida.** `ddl-auto=validate` en todos los perfiles, + pruebas incluidas, así que las pruebas de integración corren sobre el + esquema que construye Flyway, con sus triggers y restricciones. +3. **Línea base 0 para las bases anteriores a Flyway.** Supabase y las bases + locales ya tienen el esquema pero no la tabla de historial. Con + `baseline-on-migrate` y `baseline-version: 0`, Flyway las marca como + anteriores a V1 y aplica V1 y V2 encima. Como los dos scripts son + idempotentes desde el ADR-0005, se saltan lo que ya existe y crean lo que + falte. Una base vacía se migra desde V1 sin línea base. +4. **RLS también para el historial de Flyway.** Flyway crea + `flyway_schema_history` en `public`, y en Supabase toda tabla nueva de + `public` hereda permisos para `anon` y `authenticated`. No puede ir en una + migración, porque Flyway tiene bloqueada esa tabla mientras migra y el + `alter table` esperaría para siempre. La cubre el callback + `afterMigrate__rls_historial_flyway.sql`, que corre al terminar cada + `migrate`, cuando ya soltó el bloqueo, y es idempotente. Cada + migración que cree tablas habilita RLS en ellas; `DatabaseMigrationsIT` y + el workflow de base de datos fallan si alguna tabla de `public` queda sin RLS. +5. **El despliegue migra.** En Render, la aplicación aplica las migraciones + pendientes al arrancar, antes de que Hibernate valide. Se acabó el paso + manual en el SQL Editor. + +## Alternativas consideradas + +1. **Línea base 1 (`baseline-version: 1`):** es la configuración habitual, + pero da V1 por aplicada sin ejecutarla. Si una base hubiera quedado a medias + (por ejemplo, sin el trigger de motivo o sin RLS), la diferencia quedaría + oculta para siempre. Con la línea base 0 y scripts idempotentes, V1 y V2 + completan lo que falte. +2. **Ejecutar las migraciones con el plugin de Maven o la CLI de Flyway, aparte + de la aplicación:** separa el despliegue del esquema del de la aplicación, + pero exige credenciales de Supabase en CI y un paso más que coordinar en + Render. Con una sola instancia y migraciones pequeñas, migrar al arrancar + es más simple y no deja ventanas en que el código y el esquema discrepen. +3. **Liquibase:** resuelve lo mismo, pero sus *changelogs* en XML o YAML + ocultan el SQL, y este proyecto depende de SQL específico de PostgreSQL + (`exclude using gist`, rangos, triggers en PL/pgSQL). Flyway ejecuta SQL + plano, que es lo que el equipo ya escribe y revisa. + +## Consecuencias + +- (+) Las pruebas de integración ejercitan las reglas de la base de datos. Un + caso de uso que viole un trigger o una restricción falla en las pruebas, no + en producción. +- (+) Cada cambio de esquema es un archivo revisable en su pull request, y cada + base registra qué versión tiene. +- (+) Desplegar ya no exige pasos manuales en Supabase. +- (−) V1 y V2 tienen que seguir siendo idempotentes, porque se vuelven a + aplicar sobre las bases anteriores a Flyway. El workflow de base de datos + los ejecuta dos veces para comprobarlo. Las migraciones siguientes no lo + necesitan. +- (−) La aplicación migra la base a la que apunta `DATABASE_URL`. Arrancarla + en local apuntando a Supabase con una rama que traiga migraciones nuevas las + aplica a la base compartida antes de la revisión. El desarrollo local va + contra el PostgreSQL de `docker compose`. +- (−) Ya no hay un único archivo con el esquema completo. Para leerlo entero + están V1, las migraciones siguientes en orden y `modelo-logico.md`. +- (−) Una migración que falle en Render deja la instancia sin arrancar. Por + eso cada migración se prueba primero en las pruebas de integración y en el + workflow de base de datos, sobre PostgreSQL 16. + +Ver `src/main/resources/db/migration/` (las migraciones), +`docs/database/README.md` (cómo trabajar con ellas), +`CloudProfileContextIT` (la primera migración de Supabase, simulada) y +`DatabaseMigrationsIT` (una base vacía migrada desde V1). diff --git a/docs/database/README.md b/docs/database/README.md index 4b4a6fc..7413671 100644 --- a/docs/database/README.md +++ b/docs/database/README.md @@ -2,15 +2,31 @@ Qué hay aquí, en qué orden se ejecuta y cómo levantarlo para trabajar. -## Los archivos +## Dónde está el esquema + +El modelo físico son las migraciones de Flyway en +`src/main/resources/db/migration/` (ADR-0006). La aplicación las aplica al +arrancar, en orden de versión: + +| Migración | Qué hace | +|---|---| +| `V1__esquema_inicial.sql` | Las diecinueve tablas del Sprint 1 con sus restricciones, triggers e índices. Es el antiguo `schema.sql` (ADR-0005) | +| `V2__rls.sql` | Habilita Row Level Security en las diecinueve tablas. Es el antiguo `rls.sql` | +| `afterMigrate__rls_historial_flyway.sql` | Callback, no migración: corre al final de cada `migrate` y habilita RLS en `flyway_schema_history`, que Flyway tiene bloqueada mientras migra | + +V1 y V2 no se modifican nunca: Flyway guarda su checksum y no arranca si +cambian. Un cambio de esquema es una migración nueva, `V3__.sql` +y siguientes. Si crea tablas, habilita RLS en ellas en la misma migración; +`DatabaseMigrationsIT` y el workflow de base de datos fallan si alguna tabla de +`public` queda sin RLS. + +## Los archivos de esta carpeta | Archivo | Qué es | ¿Se ejecuta? | |---|---|---| -| `schema.sql` | Modelo físico. Diecinueve tablas con sus restricciones e índices. Es la fuente del esquema (ADR-0005) | Sí, primero | -| `seed.sql` | Datos de prueba. Tres organizaciones, cinco servicios, ocho reservas | Sí, después del esquema | +| `seed.sql` | Datos de prueba. Tres organizaciones, cinco servicios, ocho reservas | Sí, después de las migraciones | | `consultas-clave.sql` | Las catorce preguntas de negocio, ejecutables de corrido | Sí, para ver resultados | | `pruebas-integridad.sql` | Dieciocho instrucciones que violan una regla cada una y deben ser rechazadas | Sí, para comprobar | -| `rls.sql` | Habilita Row Level Security en las diecinueve tablas | Sí, al final | | `modelo-logico.md` | Diagrama entidad-relación, decisiones de modelado y normalización | No, se lee | | `consultas-clave.md` | Las mismas catorce preguntas con su explicación | No, se lee | | `diagrama-er.drawio` | Diagrama editable. La primera página, **Modelo completo**, tiene las diecinueve entidades, las notas y las historias de usuario en una sola hoja; las cuatro siguientes son las tres vistas por dominio y la hoja de historias de usuario | No | @@ -22,12 +38,16 @@ ejecutan con `psql -f` y no pegándolos en el editor SQL de Supabase. ## Levantarlo en local -El `docker-compose.yml` de la raíz ya trae un PostgreSQL 16. No hace falta nada -más: +El `docker-compose.yml` de la raíz ya trae un PostgreSQL 16. Arranca la +aplicación contra él (`DATABASE_URL=jdbc:postgresql://localhost:5432/postgres`) +y Flyway crea el esquema. Si solo quieres el esquema y los datos, sin la +aplicación, aplica las migraciones a mano en orden de versión: ```bash docker compose up -d db -docker compose exec -T db psql -U postgres -d postgres -v ON_ERROR_STOP=1 < docs/database/schema.sql +for f in $(ls src/main/resources/db/migration/V*__*.sql | sort -V); do + docker compose exec -T db psql -U postgres -d postgres -v ON_ERROR_STOP=1 < "$f" +done docker compose exec -T db psql -U postgres -d postgres -v ON_ERROR_STOP=1 < docs/database/seed.sql docker compose exec -T db psql -U postgres -d postgres < docs/database/consultas-clave.sql ``` @@ -43,29 +63,45 @@ Cuando termines: `docker compose down -v` borra también el volumen. **`seed.sql` empieza con un `truncate`.** Tiene una guarda que aborta si la base no parece local, pero aun así no lo ejecutes contra la base compartida. -## Aplicarlo en Supabase +Si aplicaste las migraciones a mano y después arrancas la aplicación contra +esa misma base, no pasa nada: Flyway la marca con la línea base 0 y vuelve a +aplicar V1 y V2, que son idempotentes. -`schema.sql` es idempotente: se salta `clients` y `login_attempts`, que ya -existen porque las crea el mapeo JPA, y crea las diecisiete restantes sin tocar -ninguna columna de las dos primeras. Después hay que ejecutar `rls.sql`, porque -sin él las tablas nuevas quedan expuestas por PostgREST con el anon key, que es -público (ADR-0002). +## Supabase + +Ya no se ejecuta nada a mano en el SQL Editor. En cada despliegue, la +aplicación aplica a Supabase las migraciones pendientes. La primera vez que +arranque con Flyway, la base ya tiene el esquema del Sprint 1 pero no el +historial: Flyway la marca con la línea base 0 y vuelve a aplicar V1 y V2, que +se saltan lo que ya existe (lo comprueba `CloudProfileContextIT`). + +Por lo mismo, no arranques la aplicación desde tu máquina apuntando a Supabase +con una rama que traiga migraciones nuevas: se aplicarían a la base compartida +antes de la revisión. No ejecutes `seed.sql` ni `pruebas-integridad.sql` contra Supabase. ## Qué verifica la integración continua El workflow `.github/workflows/database.yml` corre en cada pull request que -toque esta carpeta o el mapeo JPA, y comprueba que el DDL ejecute contra una -base vacía, que sea idempotente, que se creen las diecinueve tablas, que los -datos de prueba carguen, que las catorce consultas devuelvan filas y que las -dieciocho pruebas de integridad sean rechazadas. +toque esta carpeta, las migraciones o el mapeo JPA. Comprueba que: + +- las migraciones ejecuten contra una base vacía; +- V1 y V2 sigan siendo idempotentes; +- se creen las diecinueve tablas; +- los datos de prueba carguen; +- las catorce consultas devuelvan filas; +- las dieciocho pruebas de integridad sean rechazadas; +- ninguna tabla quede sin RLS. + +Las pruebas de integración del build corren sobre el esquema que crea Flyway, +así que los triggers y las restricciones también actúan en ellas. Si tocas el esquema y el workflow se pone rojo, lo más probable es que haya que actualizar `seed.sql` o alguno de los conteos del workflow. ## Qué falta -Está listado al final de `schema.sql` y de `modelo-logico.md`: reglas de negocio +Está listado al final de `V1__esquema_inicial.sql` y de `modelo-logico.md`: reglas de negocio que el esquema no puede garantizar de forma declarativa y siguen viviendo en la aplicación. Conviene leerlo antes de dar algo por cubierto. diff --git a/docs/database/consultas-clave.md b/docs/database/consultas-clave.md index a9b70b4..c34ff93 100644 --- a/docs/database/consultas-clave.md +++ b/docs/database/consultas-clave.md @@ -1,7 +1,7 @@ # Consultas clave — Plataforma de Reservas Preguntas de negocio del caso traducidas a SQL. Todas se ejecutan sobre las -tablas que crea `schema.sql` y todas devuelven filas con los datos de +tablas que crean las migraciones de Flyway y todas devuelven filas con los datos de `seed.sql`. La versión ejecutable de corrido, con separadores, está en `consultas-clave.sql`; el workflow `database.yml` la corre en cada pull request y falla si alguna deja de devolver resultado. @@ -234,6 +234,6 @@ where l.status = 'ACTIVE' order by o.name, l.name; ``` -Ver `schema.sql` (modelo físico), `modelo-logico.md` (entidades y +Ver las migraciones de `src/main/resources/db/migration/` (modelo físico), `modelo-logico.md` (entidades y normalización), `seed.sql` (los datos sobre los que corren estas consultas) y `consultas-clave.sql` (la versión ejecutable). diff --git a/docs/database/consultas-clave.sql b/docs/database/consultas-clave.sql index a37e1f7..7f3e647 100644 --- a/docs/database/consultas-clave.sql +++ b/docs/database/consultas-clave.sql @@ -2,13 +2,14 @@ -- Consultas clave del negocio -- Sprint 1 -- --- Catorce preguntas. Todas corren sobre las tablas de schema.sql y todas +-- Catorce preguntas. Todas corren sobre las tablas de las migraciones y todas -- devuelven filas con los datos de seed.sql. -- -- Usa metacomandos de psql (\echo), así que se ejecuta con `psql -f`, -- no pegándolo en el editor SQL de Supabase. -- --- Uso: psql -f schema.sql -f seed.sql -f consultas-clave.sql +-- Uso: con las migraciones de src/main/resources/db/migration ya aplicadas, +-- psql -f seed.sql -f consultas-clave.sql -- ===================================================================== diff --git a/docs/database/modelo-logico.md b/docs/database/modelo-logico.md index 3fa3355..8fc7536 100644 --- a/docs/database/modelo-logico.md +++ b/docs/database/modelo-logico.md @@ -1,7 +1,7 @@ # Modelo lógico — Plataforma de Reservas de Servicios Modelo entidad-relación del dominio completo del caso. Las diecinueve entidades -existen en `schema.sql`; las marcadas con **(S1)** además tienen entidad JPA y +existen en la migración `src/main/resources/db/migration/V1__esquema_inicial.sql`; las marcadas con **(S1)** además tienen entidad JPA y están en producción, el resto es modelo de datos adelantado al código. Las credenciales (correo, contraseña, confirmación) viven en `auth.users` de @@ -273,7 +273,7 @@ aplicación. Se listan para que no se den por cubiertas: roles dentro de una organización, no el rol global. - Excepciones de agenda como festivos o mantenimientos. -Ver `schema.sql` (modelo físico), `consultas-clave.md` (preguntas de negocio y +Ver las migraciones de `src/main/resources/db/migration/` (modelo físico), `consultas-clave.md` (preguntas de negocio y su SQL), `seed.sql` (datos de prueba), `pruebas-integridad.sql` (las -restricciones rechazando lo que deben rechazar) y `rls.sql` (seguridad +restricciones rechazando lo que deben rechazar) y `V2__rls.sql` (seguridad Supabase). El diagrama editable está en `diagrama-er.drawio`. diff --git a/docs/database/pruebas-integridad.sql b/docs/database/pruebas-integridad.sql index 03b7569..0e00b5b 100644 --- a/docs/database/pruebas-integridad.sql +++ b/docs/database/pruebas-integridad.sql @@ -6,7 +6,8 @@ -- motor, no la aplicación. Cada instrucción de este archivo viola una -- regla de negocio a propósito, y todas deben ser rechazadas. -- --- Uso: psql -f schema.sql -f seed.sql -f pruebas-integridad.sql +-- Uso: con las migraciones de src/main/resources/db/migration ya aplicadas, +-- psql -f seed.sql -f pruebas-integridad.sql -- Se ejecuta SIN ON_ERROR_STOP para que se vean los dieciocho rechazos. -- Usa metacomandos de psql, así que se ejecuta con `psql -f`. -- ===================================================================== diff --git a/docs/database/seed.sql b/docs/database/seed.sql index 1d99551..e7b4f1e 100644 --- a/docs/database/seed.sql +++ b/docs/database/seed.sql @@ -2,7 +2,7 @@ -- Datos de prueba — Plataforma de Reservas de Servicios -- Sprint 1 -- --- Se ejecuta DESPUÉS de schema.sql. Carga un escenario pequeño pero +-- Se ejecuta DESPUÉS de las migraciones de Flyway. Carga un escenario pequeño pero -- completo, pensado para que TODAS las consultas clave devuelvan -- filas: una consulta que devuelve vacío no demuestra nada. -- diff --git a/docs/operations/runbook.md b/docs/operations/runbook.md index 1dc17de..b25da98 100644 --- a/docs/operations/runbook.md +++ b/docs/operations/runbook.md @@ -41,19 +41,18 @@ devolver 401 es el comportamiento esperado, no una falla. 3. **Auth → Providers → Email:** confirmación de correo activada, longitud mínima 8. 4. **Auth → Settings → JWT expiry:** `900` segundos (15 min, HU-021). -5. **Tablas:** ejecutar `docs/database/schema.sql` en el SQL Editor. Es - idempotente, así que se puede aplicar sobre una base que ya tenga las tablas - de Hibernate: se las salta y crea las restantes (ADR-0005). Verificar: +5. **Tablas y RLS:** no se ejecuta nada a mano. Al arrancar, la aplicación + aplica con Flyway las migraciones de `src/main/resources/db/migration/` + (ADR-0006). Sobre la base que ya tenía el esquema del Sprint 1, aplicado a + mano, la primera vez Flyway crea la línea base 0 y vuelve a aplicar V1 y V2, + que son idempotentes. Verificar después del despliegue: ```sql - select count(*) from pg_class - where relnamespace = 'public'::regnamespace and relkind = 'r'; - -- deben ser 19 - ``` -6. **RLS (después de las tablas):** ejecutar `docs/database/rls.sql` en el - SQL Editor y verificar con: - ```sql - select relname as tabla, relrowsecurity as rls_habilitado from pg_class - where relname in ('clients','login_attempts'); + select version, type, success from flyway_schema_history order by installed_rank; + -- 0 BASELINE, 1 SQL, 2 SQL … todas con success = true + + select relname from pg_class + where relnamespace = 'public'::regnamespace and relkind = 'r' and not relrowsecurity; + -- ninguna fila: todas las tablas, incluido el historial de Flyway, tienen RLS ``` El backend conecta como dueño y bypasea RLS; el Data API público queda bloqueado. diff --git a/pom.xml b/pom.xml index 0cfefa4..36e289c 100644 --- a/pom.xml +++ b/pom.xml @@ -69,6 +69,15 @@ postgresql runtime + + + org.springframework.boot + spring-boot-starter-flyway + + + org.flywaydb + flyway-database-postgresql + diff --git a/src/main/resources/application-cloud.yml b/src/main/resources/application-cloud.yml index 3d3f5ef..9cca12d 100644 --- a/src/main/resources/application-cloud.yml +++ b/src/main/resources/application-cloud.yml @@ -6,9 +6,6 @@ spring: # pool small so a single instance never exhausts it. maximum-pool-size: 5 minimum-idle: 1 - jpa: - hibernate: - ddl-auto: validate logging: level: diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index f1af3d4..993b400 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -7,10 +7,19 @@ spring: password: ${DATABASE_PASSWORD:postgres} hikari: maximum-pool-size: 10 + flyway: + # Databases created before Flyway (Supabase and local ones from Sprint 1) + # already hold the schema but no history table. Baseline 0 marks them as + # predating V1, so V1 and V2, which are idempotent, still run on them and + # create whatever is missing. A new empty database just migrates (ADR-0006). + baseline-on-migrate: true + baseline-version: 0 jpa: open-in-view: false hibernate: - ddl-auto: ${JPA_DDL_AUTO:update} + # Flyway owns the schema in every profile; Hibernate only checks that + # the entity mapping matches it. + ddl-auto: validate properties: hibernate: jdbc: diff --git a/docs/database/schema.sql b/src/main/resources/db/migration/V1__esquema_inicial.sql similarity index 97% rename from docs/database/schema.sql rename to src/main/resources/db/migration/V1__esquema_inicial.sql index 534efcf..d6d2b6a 100644 --- a/docs/database/schema.sql +++ b/src/main/resources/db/migration/V1__esquema_inicial.sql @@ -1,14 +1,22 @@ -- ===================================================================== -- Plataforma de Reservas de Servicios — modelo físico --- Sprint 1 +-- Migración V1: el esquema del Sprint 1, tal como se aplicaba a mano +-- antes de Flyway (ADR-0005, ADR-0006). -- -- PostgreSQL 15+ (probado en 16). Compatible con Supabase. -- Contra una base vacía crea las 19 tablas sin errores. -- --- También corre contra una base que ya tenga `clients` y --- `login_attempts` del mapeo JPA: esas dos se saltan, y sus --- restricciones se añaden con bloques DO porque --- `create table if not exists` no las agrega si la tabla ya existe. +-- Es idempotente a propósito, y tiene que seguir siéndolo: las bases +-- creadas antes de Flyway (Supabase y las locales) ya tienen este +-- esquema pero no el historial. Flyway las marca con la línea base 0 +-- y les vuelve a aplicar V1, que se salta lo que ya existe y crea lo +-- que falte. También corre contra una base que sólo tenga `clients` y +-- `login_attempts` del antiguo `ddl-auto=update`: sus restricciones se +-- añaden con bloques DO porque `create table if not exists` no las +-- agrega si la tabla ya existe. +-- +-- NO SE MODIFICA. Flyway guarda su checksum y rechaza arrancar si +-- cambia. Cualquier cambio de esquema va en una migración nueva. -- -- Lo que este esquema NO garantiza está al final del archivo. -- ===================================================================== diff --git a/docs/database/rls.sql b/src/main/resources/db/migration/V2__rls.sql similarity index 83% rename from docs/database/rls.sql rename to src/main/resources/db/migration/V2__rls.sql index bb42d9a..c9a9a3a 100644 --- a/docs/database/rls.sql +++ b/src/main/resources/db/migration/V2__rls.sql @@ -1,6 +1,7 @@ -- ============================================================= -- Seguridad Supabase — Row Level Security --- Ejecutar DESPUÉS de crear las tablas (schema.sql o ddl-auto). +-- Migración V2. Va después de V1 porque necesita las tablas. +-- Como V1, es idempotente: en Supabase ya se había aplicado a mano. -- -- Contexto: el anon key de Supabase es público y PostgREST -- expone el esquema `public`. Sin RLS, cualquier persona con @@ -13,6 +14,15 @@ -- negocio, así que este script cubre las diecinueve. Con RLS -- habilitado y sin políticas, el modo por defecto es denegar. -- +-- El historial de Flyway (`flyway_schema_history`) no va aquí: +-- Flyway lo tiene bloqueado mientras migra, y el ALTER TABLE se +-- quedaría esperando para siempre. Lo cubre el callback +-- `afterMigrate__rls_historial_flyway.sql`. +-- +-- Las tablas que creen migraciones posteriores habilitan RLS en +-- su propia migración; la prueba DatabaseMigrationsIT falla si +-- alguna tabla de `public` queda sin él. +-- -- Los bloques DO permiten ejecutarlo también contra un -- PostgreSQL local, donde los roles `anon` y `authenticated` -- no existen: allí basta con habilitar RLS y omitir el REVOKE. @@ -31,10 +41,12 @@ declare 'account_status_changes' ]; hay_roles boolean := exists (select 1 from pg_roles where rolname = 'anon'); + n integer := 0; begin foreach t in array tablas loop if to_regclass('public.' || t) is not null then execute format('alter table public.%I enable row level security', t); + n := n + 1; if hay_roles then execute format('revoke all on public.%I from anon, authenticated', t); end if; @@ -42,9 +54,9 @@ begin end loop; if hay_roles then - raise notice 'RLS habilitado y acceso Data API revocado en % tablas', array_length(tablas, 1); + raise notice 'RLS habilitado y acceso Data API revocado en % tablas', n; else - raise notice 'RLS habilitado en % tablas. Los roles anon/authenticated no existen en esta base (PostgreSQL local): se omite el REVOKE', array_length(tablas, 1); + raise notice 'RLS habilitado en % tablas. Los roles anon/authenticated no existen en esta base (PostgreSQL local): se omite el REVOKE', n; end if; end $$; diff --git a/src/main/resources/db/migration/afterMigrate__rls_historial_flyway.sql b/src/main/resources/db/migration/afterMigrate__rls_historial_flyway.sql new file mode 100644 index 0000000..ec23026 --- /dev/null +++ b/src/main/resources/db/migration/afterMigrate__rls_historial_flyway.sql @@ -0,0 +1,23 @@ +-- ============================================================= +-- Callback de Flyway: corre al final de cada `migrate`, cuando +-- ya soltó el bloqueo de su tabla de historial. +-- +-- Flyway crea `flyway_schema_history` en `public`, y en Supabase +-- toda tabla nueva de `public` hereda permisos para anon y +-- authenticated: sin esto, cualquiera con el anon key podría leer +-- o borrar el historial de migraciones por el Data API (ADR-0002). +-- +-- Corre en cada arranque, así que tiene que ser idempotente. Como +-- en V2, en un PostgreSQL local sin los roles de Supabase solo se +-- habilita RLS. +-- ============================================================= + +alter table if exists flyway_schema_history enable row level security; + +do $$ +begin + if exists (select 1 from pg_roles where rolname = 'anon') + and to_regclass('public.flyway_schema_history') is not null then + revoke all on public.flyway_schema_history from anon, authenticated; + end if; +end $$; diff --git a/src/test/java/com/codefactory/bookingplatform/startup/CloudProfileContextIT.java b/src/test/java/com/codefactory/bookingplatform/startup/CloudProfileContextIT.java index e1adc88..b43786b 100644 --- a/src/test/java/com/codefactory/bookingplatform/startup/CloudProfileContextIT.java +++ b/src/test/java/com/codefactory/bookingplatform/startup/CloudProfileContextIT.java @@ -5,6 +5,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.context.ApplicationContext; +import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.DynamicPropertyRegistry; import org.springframework.test.context.DynamicPropertySource; @@ -14,41 +15,43 @@ import java.nio.file.Path; import java.sql.Connection; import java.sql.Statement; +import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; /** - * The profile that actually ships. Render runs the image with - * {@code SPRING_PROFILES_ACTIVE=cloud}, and that profile pins - * {@code ddl-auto=validate}: Hibernate refuses to start if the database does - * not already match the entity mapping. + * The profile that actually ships, started on the database it will find on its first deploy with + * Flyway. Render runs the image with {@code SPRING_PROFILES_ACTIVE=cloud}; Supabase already holds + * the Sprint 1 schema, applied by hand from the SQL Editor, and no Flyway history table. * - *

So the container is created from {@code docs/database/schema.sql}, the - * physical model committed for Sprint 1, and the context is started on top of - * it. A green run here is the evidence that the documented DDL and the code - * agree; a red one is a deployment that dies on boot with no way to recover - * from the outside. + *

So the container is prepared the same way, by running V1 and V2 directly, and the context is + * started on top of it. Flyway has to baseline that database and re-apply both migrations without + * failing, and Hibernate's {@code validate} has to accept the result. A red run here is a + * deployment that dies on boot with no way to recover from the outside. */ @SpringBootTest @ActiveProfiles("cloud") class CloudProfileContextIT { - private static final Path SCHEMA = Path.of("docs", "database", "schema.sql"); + private static final Path MIGRATIONS = Path.of("src", "main", "resources", "db", "migration"); static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine"); static { POSTGRES.start(); - applyCommittedSchema(); + applyByHand("V1__esquema_inicial.sql"); + applyByHand("V2__rls.sql"); } - private static void applyCommittedSchema() { + /** What the Sprint 1 runbook did: run the script as-is, outside Flyway. */ + private static void applyByHand(String migration) { + Path script = MIGRATIONS.resolve(migration); try (Connection connection = java.sql.DriverManager.getConnection( POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword()); Statement statement = connection.createStatement()) { - statement.execute(Files.readString(SCHEMA)); + statement.execute(Files.readString(script)); } catch (Exception ex) { - throw new IllegalStateException("Could not apply " + SCHEMA.toAbsolutePath(), ex); + throw new IllegalStateException("Could not apply " + script.toAbsolutePath(), ex); } } @@ -62,8 +65,11 @@ static void registerDatasourceProperties(DynamicPropertyRegistry registry) { @Autowired private ApplicationContext context; + @Autowired + private JdbcTemplate jdbc; + @Test - @DisplayName("ddl-auto is validate, so Hibernate verified every mapping against the committed DDL") + @DisplayName("ddl-auto is validate, so Hibernate verified every mapping against the migrated schema") void schemaIsValidatedNotCreated() { // Act String ddlAuto = context.getEnvironment().getProperty("spring.jpa.hibernate.ddl-auto"); @@ -71,4 +77,16 @@ void schemaIsValidatedNotCreated() { // Assert assertEquals("validate", ddlAuto); } + + @Test + @DisplayName("A database that predates Flyway is baselined at 0 and V1 and V2 are re-applied over it") + void preFlywayDatabaseIsBaselinedAndMigrated() { + // Act + List history = jdbc.queryForList( + "select version || ':' || type || ':' || success from flyway_schema_history order by installed_rank", + String.class); + + // Assert + assertEquals(List.of("0:BASELINE:true", "1:SQL:true", "2:SQL:true"), history); + } } diff --git a/src/test/java/com/codefactory/bookingplatform/startup/DatabaseMigrationsIT.java b/src/test/java/com/codefactory/bookingplatform/startup/DatabaseMigrationsIT.java new file mode 100644 index 0000000..e48cb67 --- /dev/null +++ b/src/test/java/com/codefactory/bookingplatform/startup/DatabaseMigrationsIT.java @@ -0,0 +1,67 @@ +package com.codefactory.bookingplatform.startup; + +import com.codefactory.bookingplatform.support.PostgresIntegrationTestBase; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The schema every integration test runs on is the one Flyway builds from + * {@code db/migration}, not one Hibernate derives from the entities. These checks pin that down, + * plus the one rule each new migration has to honour by hand: no table in {@code public} without + * row level security, since Supabase exposes that schema through the Data API (ADR-0002). + * + *

Arrange-Act-Assert: the migrated container is the shared fixture; the Act is one catalog + * query and the Assert reads its rows. + */ +class DatabaseMigrationsIT extends PostgresIntegrationTestBase { + + @Autowired + private JdbcTemplate jdbc; + + @Test + @DisplayName("An empty database is migrated from V1 with no baseline row") + void emptyDatabaseIsMigratedFromTheFirstVersion() { + // Act + List history = jdbc.queryForList( + "select version || ':' || type || ':' || success from flyway_schema_history order by installed_rank", + String.class); + + // Assert + assertEquals("1:SQL:true", history.get(0)); + assertTrue(history.stream().allMatch(row -> row.endsWith(":SQL:true")), history.toString()); + } + + @Test + @DisplayName("The rules that live only in the database exist, which ddl-auto would never have created") + void databaseOnlyRulesExist() { + // Act + Integer rules = jdbc.queryForObject(""" + select (select count(*) from pg_trigger where tgname = 'tg_bookings_reglas') + + (select count(*) from pg_constraint where conname = 'ex_booking_resources_solape') + """, Integer.class); + + // Assert + assertEquals(2, rules); + } + + @Test + @DisplayName("Every table in public has row level security, Flyway's history table included") + void everyPublicTableHasRowLevelSecurity() { + // Act + List exposed = jdbc.queryForList(""" + select relname from pg_class + where relnamespace = 'public'::regnamespace and relkind = 'r' and not relrowsecurity + order by relname + """, String.class); + + // Assert + assertEquals(List.of(), exposed); + } +} diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index a41515e..e17962c 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -1,7 +1,5 @@ spring: jpa: - hibernate: - ddl-auto: create-drop properties: hibernate: jdbc: