Compare commits

7 Commits

Author SHA1 Message Date
markosbenitez
b3f039fbd5 fix(ui): toggle password en login + fondo blanco card talento
Fix A: botón inline con ícono SVG (ojo/ojo tachado) en el campo
password de /login, alterna type password/text, aria-label según
estado. Sin librería nueva.
Fix B: .db-widget-star pierde el gradiente, hereda background blanco
de .db-widget igual que los demás cards. Borde y título teal intactos.
2026-06-27 15:18:22 -03:00
markosbenitez
430bf7a5da feat(compliance): audit_log append-only — user_roles y surveys.status [SEC-002]
Tabla audit_log con RLS (solo platform_admin), trigger BEFORE UPDATE/DELETE
que aborta (append-only enforcement), triggers AFTER en user_roles y
surveys.status. Patrón SECURITY DEFINER reutilizado de auth_user_roles.
Event trigger DDL incluido (confirmado viable sin extensiones, ver
diagnóstico en ETAPA_AUDIT_LOG_PROMPT.md §4.1). Gap SEC-003 (TRUNCATE por
superusuario) documentado y aceptado, no resuelto.

NO aplicada en VPS — pendiente de aprobación.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 14:51:07 -03:00
markosbenitez
10bb7299a2 docs: actualizar estado — cierre release v3.0.0 + ETAPA_AUTH + incidente VPS
Sprint 4 completo (widgets Mirada Empresa, tema claro, 4F, 4I/release ya
estaban hechos antes de esta sesión). Documenta el incidente de colisión de
container_name en el VPS y su recuperación, y el cierre de TECH-DEBT-008
(auth de staff) en esta sesión. Próximos pasos quedan abiertos a decisión
del director (dashboard País/Humana, sprint de compliance, o mantenimiento
de dependencias).
2026-06-27 13:38:10 -03:00
markosbenitez
dda4b5320a fix(auth): agregar email_change/email_change_token_new en setup_first_admin
GoTrue fallaba con "Database error querying schema" al hacer login porque
estas dos columnas de auth.users no tienen DEFAULT en este esquema y quedaban
NULL — el driver Go espera string, no NULL. Verificado en VPS: login
funciona después de este fix.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 13:18:32 -03:00
markosbenitez
44d1f4b9d9 fix(auth): corregir setup_first_admin — psql vars no funcionan en DO $$
Reescribir con CTE INSERT...RETURNING para encadenar auth.users + user_roles
en una sola sentencia SQL plana donde :variable sí funciona.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 09:47:38 -03:00
markosbenitez
a484ba7849 feat(auth): Supabase Auth email/password + middleware + login [auth]
Protege /admin/* con Supabase Auth. Tabla user_roles con custom claims
(app_role, org_id) en JWT. Middleware Next.js redirige a /login sin sesión.
Página /login con email/password. /admin/responses migrado de service_role
a sesión de usuario autenticado.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 09:39:52 -03:00
markosbenitez
ddaba6866a fix(rpc): filtrar elementos individuales en carrera_congelada [BUG-001]
Mover el filtro 'Ninguna de las anteriores' del WHERE sobre a.value
al WHERE del subquery expandido, donde opt es ya un elemento individual
resultado de jsonb_array_elements_text. El operador #>> '{}' aplicado
sobre un array jsonb no filtraba elementos sino la representación textual
del array completo.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 22:38:17 -03:00
13 changed files with 842 additions and 67 deletions

View File

@@ -77,14 +77,15 @@
## [TECH-DEBT-008] /admin/responses sin autenticación ## [TECH-DEBT-008] /admin/responses sin autenticación
- **Estado:** ABIERTO. - **Estado:** RESUELTO (27 jun 2026, ETAPA_AUTH).
- **Descripción:** la vista de control `/admin/responses` no tiene auth. Por diseño - **Descripción:** la vista de control `/admin/responses` no tenía auth. Se implementó
explícito, excluye preguntas `is_sensitive=true` (14 preguntas de salud/ login con Supabase Auth (`@supabase/ssr`), `middleware.ts` protegiendo
discapacidad) y `text_long`. Para mostrar esas preguntas en la vista, primero `/admin/:path*` y `/dashboard/:path*` con redirect a `/login` sin sesión, y la página
implementar un gate de autenticación para staff (Basic Auth con env var como migrada de `service_role` a la sesión del usuario (`anon` key + RLS). Sigue
mínimo viable). excluyendo preguntas `is_sensitive=true` y `text_long` — ese diseño no cambió.
- **Impacto actual:** ninguno en producción — la URL no está publicada. - **Nota:** la vista no distingue roles dentro de staff todavía (todo usuario con fila
- **Bloqueante para:** ampliar visibilidad de datos en la vista de control. en `user_roles` ve lo mismo). Diferenciar `platform_admin` de `org_admin` ahí sería
una etapa nueva, no parte de este cierre.
--- ---

View File

@@ -1,8 +1,8 @@
import { createClient } from '@supabase/supabase-js' import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
import { ResponsesTable, type DisplayRow } from './responses-table' import { ResponsesTable, type DisplayRow } from './responses-table'
import { SignOutButton } from '../sign-out-button'
// Server Component sin auth — herramienta interna de diagnóstico (Etapa 3V-B).
// Usa SUPABASE_SERVICE_ROLE_KEY: vive solo acá, server-side, nunca al cliente.
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
const MAX_SUBMISSIONS = 500 const MAX_SUBMISSIONS = 500
@@ -36,10 +36,28 @@ interface ResponseRow {
answers: AnswerRow[] answers: AnswerRow[]
} }
function getSupabaseAdmin() { async function getSupabaseSession() {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://placeholder.supabase.co' const cookieStore = await cookies()
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY || 'placeholder' return createServerClient(
return createClient(url, serviceKey, { auth: { persistSession: false } }) process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll()
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
)
} catch {
// Server Component — no puede mutar cookies, se ignora
}
},
},
}
)
} }
function formatDate(iso: string): string { function formatDate(iso: string): string {
@@ -76,7 +94,7 @@ export default async function AdminResponsesPage({
searchParams: Promise<{ survey_id?: string }> searchParams: Promise<{ survey_id?: string }>
}) { }) {
const { survey_id: surveyId } = await searchParams const { survey_id: surveyId } = await searchParams
const supabase = getSupabaseAdmin() const supabase = await getSupabaseSession()
// 1. Preguntas permitidas — text_long e is_sensitive=true se excluyen ACÁ, // 1. Preguntas permitidas — text_long e is_sensitive=true se excluyen ACÁ,
// antes de tocar la tabla answers. Sus valores nunca se consultan. // antes de tocar la tabla answers. Sus valores nunca se consultan.
@@ -214,7 +232,10 @@ export default async function AdminResponsesPage({
))} ))}
</nav> </nav>
</div> </div>
<div className="admin-header-actions">
<a href="/admin/responses" className="admin-refresh">Actualizar</a> <a href="/admin/responses" className="admin-refresh">Actualizar</a>
<SignOutButton />
</div>
</header> </header>
<div className="admin-table-wrapper"> <div className="admin-table-wrapper">

View File

@@ -0,0 +1,24 @@
'use client'
import { createBrowserClient } from '@supabase/ssr'
import { useRouter } from 'next/navigation'
export function SignOutButton() {
const router = useRouter()
const supabase = createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
async function handleSignOut() {
await supabase.auth.signOut()
router.push('/login')
}
return (
<button type="button" onClick={handleSignOut} className="admin-signout">
Cerrar sesión
</button>
)
}

View File

@@ -964,7 +964,6 @@ body {
/* ── Card destacada (estrella) ── */ /* ── Card destacada (estrella) ── */
.db-widget-star { .db-widget-star {
border-color: var(--color-re-teal); border-color: var(--color-re-teal);
background: linear-gradient(135deg, #dff4f5, #ffffff);
} }
.db-widget-star .db-widget-title { color: var(--color-re-teal); } .db-widget-star .db-widget-title { color: var(--color-re-teal); }
@@ -1060,3 +1059,84 @@ body {
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } *, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
} }
/* ── Login ── */
.login-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-bg);
padding: 24px;
}
.login-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
box-shadow: var(--shadow-card);
padding: 40px 36px;
width: 100%;
max-width: 380px;
}
.login-title {
font-size: 1.25rem;
font-weight: 700;
color: var(--color-text);
margin: 0 0 28px;
}
.login-form { display: flex; flex-direction: column; gap: 16px; }
.login-field { display: flex; flex-direction: column; gap: 6px; }
.login-label {
font-size: 0.875rem;
font-weight: 600;
color: var(--color-text);
}
.login-error { margin: 0; }
.login-password-wrapper { position: relative; }
.login-password-wrapper .text-short-input { padding-right: 40px; }
.login-password-toggle {
position: absolute;
top: 50%;
right: 8px;
transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
background: transparent;
border: none;
border-radius: var(--radius);
color: var(--color-text-muted);
cursor: pointer;
}
.login-password-toggle:hover { color: var(--color-text); }
.login-submit {
margin-top: 8px;
padding: 10px 20px;
background: var(--color-primary);
color: #fff;
border: none;
border-radius: var(--radius);
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
}
.login-submit:hover:not(:disabled) { background: var(--color-primary-hover); }
.login-submit:disabled { opacity: 0.6; cursor: not-allowed; }
/* ── Admin sign-out ── */
.admin-header-actions { display: flex; align-items: center; gap: 12px; }
.admin-signout {
font-size: 0.8125rem;
color: var(--color-text-muted);
background: none;
border: 1px solid var(--color-border);
border-radius: var(--radius);
padding: 5px 12px;
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
}
.admin-signout:hover { color: var(--color-error); border-color: var(--color-error); }

103
app/login/page.tsx Normal file
View File

@@ -0,0 +1,103 @@
'use client'
import { useState } from 'react'
import { createBrowserClient } from '@supabase/ssr'
import { useRouter } from 'next/navigation'
export default function LoginPage() {
const [isPending, setIsPending] = useState(false)
const [error, setError] = useState<string | null>(null)
const [showPassword, setShowPassword] = useState(false)
const router = useRouter()
const supabase = createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
setIsPending(true)
setError(null)
const fd = new FormData(e.currentTarget)
const { error: authError } = await supabase.auth.signInWithPassword({
email: fd.get('email') as string,
password: fd.get('password') as string,
})
if (authError) {
setError('Email o contraseña incorrectos')
setIsPending(false)
return
}
router.push('/admin/responses')
router.refresh()
}
return (
<div className="login-page">
<div className="login-card">
<h1 className="login-title">Acceso interno</h1>
<form onSubmit={handleSubmit} className="login-form" noValidate>
<div className="login-field">
<label htmlFor="email" className="login-label">Email</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
disabled={isPending}
className="text-short-input"
/>
</div>
<div className="login-field">
<label htmlFor="password" className="login-label">Contraseña</label>
<div className="login-password-wrapper">
<input
id="password"
name="password"
type={showPassword ? 'text' : 'password'}
autoComplete="current-password"
required
disabled={isPending}
className="text-short-input"
/>
<button
type="button"
className="login-password-toggle"
onClick={() => setShowPassword((v) => !v)}
aria-label={showPassword ? 'Ocultar contraseña' : 'Mostrar contraseña'}
>
{showPassword ? (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z" />
<circle cx="12" cy="12" r="3" />
</svg>
) : (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z" />
<circle cx="12" cy="12" r="3" />
<path d="M3 3l18 18" />
</svg>
)}
</button>
</div>
</div>
{error && (
<p role="alert" className="field-error login-error">{error}</p>
)}
<button
type="submit"
disabled={isPending}
className="login-submit"
>
{isPending ? 'Ingresando…' : 'Ingresar'}
</button>
</form>
</div>
</div>
)
}

View File

@@ -1,5 +1,5 @@
# ESTADO DEL PROYECTO — Datos País sobre el Cuidado # ESTADO DEL PROYECTO — Datos País sobre el Cuidado
## Documento de continuidad — 25 junio 2026 ## Documento de continuidad — 27 junio 2026
> Director (Product Owner): Marcos · Claude (Projects) = arquitecto/copiloto · Claude Code = implementación > Director (Product Owner): Marcos · Claude (Projects) = arquitecto/copiloto · Claude Code = implementación
> Metodología: Maria Mersan · Contacto Fundación: Valeria Arce > Metodología: Maria Mersan · Contacto Fundación: Valeria Arce
@@ -7,17 +7,14 @@
--- ---
## 1. Versionado y ramas ## 1. Versionado y ramas
- **Producción:** 2.1.0 (formulario lineal) — los 3 surveys en `draft` - **Producción:** 3.0.0 (wizard + dashboard tres miradas) — tag `v3.0.0` pusheado, los 3 surveys
- **Desarrollo:** 3.0.0 (wizard + dashboard nuevo) en `main`, NO desplegado aún en `live`. Release v3 completado (etapa 4I).
- **Preservación:** `v2.1.0-stable` + tag `v2.1.0` - **Preservación:** `v2.1.0-stable` + tag `v2.1.0`
- SemVer + CHANGELOG + tags. ADRs en `docs/adr/`. - SemVer + CHANGELOG + tags. ADRs en `docs/adr/`.
⚠️ Los 3 surveys están en `draft` a propósito (el frontend v2 desplegado no entiende seed v3).
Se reactivan a `live` al desplegar v3 completo.
--- ---
## 2. Sprint 4 — Wizard v3 — Formulario COMPLETO, falta release ## 2. Sprint 4 — Wizard v3 — COMPLETO, release hecho
| Etapa | Estado | | Etapa | Estado |
|---|---| |---|---|
@@ -28,26 +25,41 @@ Se reactivan a `live` al desplegar v3 completo.
| 4D wizard renderer (layout estable) | ✅ | | 4D wizard renderer (layout estable) | ✅ |
| 4E instancias (emergente del motor) | ✅ | | 4E instancias (emergente del motor) | ✅ |
| 4G submit + validación server-side (RPC transaccional) | ✅ verificado vs Postgres real | | 4G submit + validación server-side (RPC transaccional) | ✅ verificado vs Postgres real |
| 4F autocomplete barrios (UX) | ⏳ pendiente | | 4F autocomplete barrios (UX) | |
| 4I validación e2e + tag v3.0.0 + desplegar + reactivar surveys | ⏳ pendiente | | Widgets Mirada Empresa (frontend) | ✅ |
| Tema claro dashboard (marca Re) | ✅ |
| 4I validación e2e + tag v3.0.0 + desplegar + reactivar surveys | ✅ |
| ETAPA_AUTH — login/middleware/sign-out para `/admin` y `/dashboard` | ✅ (27 jun) |
El formulario v3 funciona end-to-end: motor, wizard, instancias, submit con validación El formulario v3 funciona end-to-end: motor, wizard, instancias, submit con validación
server-side (RPC `rpc_submit_response`, único camino de escritura, verificado). server-side (RPC `rpc_submit_response`, único camino de escritura, verificado). Dashboard
Mirada Empresa con sus 13 widgets, consumiendo las RPCs `rpc_me_*` reales. `/admin/responses`
y `/dashboard` ahora requieren sesión autenticada (antes sin auth — cierra TECH-DEBT-008).
--- ---
## 3. Dashboard v3 — DECISIÓN MAYOR: rediseño de "tres miradas" ## 2.1 Incidente VPS (27 jun) — recuperado
Una segunda instancia de Dokploy desplegada desde el mismo repo (con `container_name` fijos en
docker-compose.yml) colisionó con la producción y vació el esquema `public` de `supabase-db`.
**Sin pérdida de datos reales** (solo seed demo). Se restauró: migraciones completas + seed +
surveys reactivados a `live` + usuario `platform_admin` creado. Verificado end-to-end: login,
sign-out, redirect sin sesión, formulario público (3 links `?s=<survey_id>`) — todo OK.
Detalle completo en memoria `project_vps_container_name_incident`. El director ya separó la
segunda instancia a un repo Gitea propio sin `container_name` fijos, para que no se repita.
---
## 3. Dashboard v3 — "tres miradas"
**Decisión:** NO migrar las RPCs v2 (referencian códigos inexistentes en v3). En su lugar, **Decisión:** NO migrar las RPCs v2 (referencian códigos inexistentes en v3). En su lugar,
dashboard nuevo de tres miradas, fundamentado en marcos reales (OIT 5R, CEPAL, PNCUPA Paraguay). dashboard nuevo de tres miradas, fundamentado en marcos reales (OIT 5R, CEPAL, PNCUPA Paraguay).
### Estado ### Estado
- **Mirada Empresa:** ✅ RPCs implementadas y VERIFICADAS contra 50 respuestas reales. - **Mirada Empresa:** ✅ RPCs + widgets de frontend completos y en producción. E1-E10 + estrella
E1-E4 exactos, estrella "Talento en riesgo" con umbral parametrizable, E9 segmentación "Talento en riesgo", k≥5 en segmentación. Tema claro aplicado.
con k≥5. Migraciones `20260625000001` (fix helper) + `20260625000002` (11 RPCs). Commiteado.
Widgets del frontend: PENDIENTE (etapa siguiente).
- **Mirada País y Mirada Humana:** propuesta + mockup listos, implementación DIFERIDA a - **Mirada País y Mirada Humana:** propuesta + mockup listos, implementación DIFERIDA a
sprint de dashboard post-deadline. sprint de dashboard post-deadline (sin cambios desde el último estado).
### Documentos (en docs/dashboard/) ### Documentos (en docs/dashboard/)
- `PROPUESTA_DASHBOARD_v3.md` — las 3 miradas, ~30 métricas, marcos citados honestamente - `PROPUESTA_DASHBOARD_v3.md` — las 3 miradas, ~30 métricas, marcos citados honestamente
@@ -65,16 +77,25 @@ dashboard nuevo de tres miradas, fundamentado en marcos reales (OIT 5R, CEPAL, P
--- ---
## 4. Deuda técnica y seguridad ## 4. Deuda técnica y seguridad
~~TECH-DEBT-008 (`/admin/responses` sin autenticación)~~**resuelto** por ETAPA_AUTH (27 jun).
| ID | Descripción | Prioridad | | ID | Descripción | Prioridad |
|---|---|---| |---|---|---|
| TECH-DEBT-006 | Backup remoto S3 | Antes de datos reales | | TECH-DEBT-006 | Backup remoto S3 | Antes de datos reales |
| TECH-DEBT-009 | Rotar secrets post-CVE | Antes de datos reales | | TECH-DEBT-009 | Rotar secrets post-CVE | Antes de datos reales |
| TECH-DEBT-010 | E9 lee modalidad por JOIN a answers (2.7) en vez de qi_modalidad. Promover si se usa seguido | Baja | | TECH-DEBT-010 | E9 lee modalidad por JOIN a answers (2.7) en vez de qi_modalidad. Promover si se usa seguido | Baja |
| SEC-001 | Next.js — vuln "Cache Key Confusion" vigente en 15.3.8; última mayor es 16.2.9 (breaking) | Media |
| TECH-001 | `npm audit`: 4 vulns (1 moderate, 3 high) — `form-data`, `next`, `undici` | Pendiente revisión |
| SEC-002 | Sprint compliance (audit log, hashing, cifrado en reposo) | Sprint dedicado | | SEC-002 | Sprint compliance (audit log, hashing, cifrado en reposo) | Sprint dedicado |
| SEC-003 | Agujero TRUNCATE evade ADR-003 | Antes de datos reales | | SEC-003 | Agujero TRUNCATE evade ADR-003 | Antes de datos reales |
| SEC-004 | RPC validable por bypass REST (coherencia de flujo en Server Action) | Sprint futuro | | SEC-004 | RPC validable por bypass REST (coherencia de flujo en Server Action) | Sprint futuro |
| BACKLOG-002 | Fase 2: motor genérico | Post-deadline | | BACKLOG-002 | Fase 2: motor genérico | Post-deadline |
*(SEC-001/TECH-001 verificados 27 jun vía `npm view next version` / `npm audit --json`
los números en BACKLOG.md/TECH_DEBT.md estaban desactualizados, ej. citaban Next 15.3.3
y "2 vulnerabilidades" cuando ya corre 15.3.8 con 4.)*
--- ---
## 5. Infraestructura ## 5. Infraestructura
@@ -87,14 +108,19 @@ dashboard nuevo de tres miradas, fundamentado en marcos reales (OIT 5R, CEPAL, P
--- ---
## 6. Próximos pasos ## 6. Próximos pasos
1. **Widgets de Mirada Empresa** — el frontend del dashboard que consume las RPCs ya verificadas.
La spec (SPEC_MIRADA_EMPRESA.md) define shapes de retorno; el mockup define el aspecto.
2. **4F autocomplete barrios** — subir `geo_paraguay_full.json` (8152 barrios) a public/data/.
3. **4I release** — validación e2e, tag v3.0.0, desplegar v3, reactivar surveys a live.
4. **Sprint de dashboard (post-deadline):** Miradas País y Humana, preguntas faltantes
(bienestar, horas no remuneradas, escolarización), decisión qi_modalidad.
Orden sugerido para go-live: widgets Mirada Empresa → 4F → 4I. **Go-live (widgets → 4F → 4I) está cerrado.** v3.0.0 en producción, surveys `live`, auth para
staff funcionando. No queda ninguna etapa de Sprint 4 pendiente — el roadmap pre-escrito se
agotó. El próximo paso requiere decisión del director entre:
1. **Sprint de dashboard (post-deadline):** Miradas País y Humana, preguntas faltantes
(bienestar, horas no remuneradas, escolarización), decisión qi_modalidad (TECH-DEBT-010).
2. **Sprint de compliance/seguridad (SEC-002):** audit log append-only, hashing de integridad,
cifrado en reposo — bloquea escalar a más empresas/datos reales.
3. **Mantenimiento de dependencias:** SEC-001 (Next.js) + TECH-001 (`npm audit`, 4 vulns).
4. Otro ítem de BACKLOG.md/TECH_DEBT.md no listado arriba.
Ninguno de estos se decide unilateralmente (Nivel 3 — tocan planificación/seguridad).
--- ---

43
middleware.ts Normal file
View File

@@ -0,0 +1,43 @@
import { createServerClient } from '@supabase/ssr'
import { NextRequest, NextResponse } from 'next/server'
export async function middleware(request: NextRequest) {
let supabaseResponse = NextResponse.next({ request })
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll()
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value }) =>
request.cookies.set(name, value)
)
supabaseResponse = NextResponse.next({ request })
cookiesToSet.forEach(({ name, value, options }) =>
supabaseResponse.cookies.set(name, value, options)
)
},
},
}
)
// getUser() valida el token contra el servidor — no confiar solo en la cookie local
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
const loginUrl = new URL('/login', request.url)
return NextResponse.redirect(loginUrl)
}
return supabaseResponse
}
export const config = {
matcher: ['/admin/:path*', '/dashboard/:path*'],
}

78
package-lock.json generated
View File

@@ -9,6 +9,7 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@hookform/resolvers": "^5.4.0", "@hookform/resolvers": "^5.4.0",
"@supabase/ssr": "^0.12.0",
"@supabase/supabase-js": "^2.49.8", "@supabase/supabase-js": "^2.49.8",
"next": "15.3.8", "next": "15.3.8",
"react": "^19.0.0", "react": "^19.0.0",
@@ -1047,9 +1048,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@supabase/auth-js": { "node_modules/@supabase/auth-js": {
"version": "2.107.0", "version": "2.108.2",
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.107.0.tgz", "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.108.2.tgz",
"integrity": "sha512-XA7x+WIeIvuC3GTZ2ey67QcBbGw4n+o5B7M+dMm9KT1lL3wX1B52DfEWW00WuPt/LnniJLLIn1WIm9YPtuxzKQ==", "integrity": "sha512-tNaQmBgodDZwgB40mRwVbxFy8IDYwjdpcZ0BYrWiwlULCSQoJj4QoG4zgJT7QRPXcqipefNOzvO/qAu4dF98ag==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"tslib": "2.8.1" "tslib": "2.8.1"
@@ -1183,9 +1184,9 @@
] ]
}, },
"node_modules/@supabase/functions-js": { "node_modules/@supabase/functions-js": {
"version": "2.107.0", "version": "2.108.2",
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.107.0.tgz", "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.108.2.tgz",
"integrity": "sha512-iMtRUmEj1KOgQd/a3MR4hnBlPnZc62DW8+z8aPpnzbxWkexEZUVL2fSgvvp15gqFg1V55e2yMGqgK+yhSQxp5w==", "integrity": "sha512-RNUX8EiBy3iLwAX19jtRzLyePnl11/fHcgwDHLnpKcDSXt/5qBnh3LUwAtIjT21Q66QsmNUR2esrHziLCpNubw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"tslib": "2.8.1" "tslib": "2.8.1"
@@ -1195,15 +1196,15 @@
} }
}, },
"node_modules/@supabase/phoenix": { "node_modules/@supabase/phoenix": {
"version": "0.4.2", "version": "0.4.4",
"resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.2.tgz", "resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.4.tgz",
"integrity": "sha512-YSAGnmDAfuleFCVt3CeurQZAhxRfXWeZIIkwp7NhYzQ1UwW6ePSnzsFAiUm/mbCkfoCf70QQHKW/K6RKh52a4A==", "integrity": "sha512-Gt0pqoXuIqX/8dvG0OKp/wMCobXNH3klNbUPBNyOfN0YA1IswrM3HyWFMOPk1Jy+BRaIyDPcFx4jLBwHNmlyfQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@supabase/postgrest-js": { "node_modules/@supabase/postgrest-js": {
"version": "2.107.0", "version": "2.108.2",
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.107.0.tgz", "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.108.2.tgz",
"integrity": "sha512-7ARs47/tyIjX7T0Ive20d4NY8zQYXsP5/P07jJWxffSIM2gpnSnGRnL/Fe15GPbdjsW2sTYeckHcyaoKbM6yWQ==", "integrity": "sha512-GQ28/Y8hk3CFmkb3kXH1h/AQx6JIYSQfO0CJMRVBcEKZoNy6C45cXAZ4fcJvRC5Id0cs6xnkUV0+c0rIocigsw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"tslib": "2.8.1" "tslib": "2.8.1"
@@ -1213,9 +1214,9 @@
} }
}, },
"node_modules/@supabase/realtime-js": { "node_modules/@supabase/realtime-js": {
"version": "2.107.0", "version": "2.108.2",
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.107.0.tgz", "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.108.2.tgz",
"integrity": "sha512-cF2KYdR3JIn9YlWGeluY9S0G+otqTdL6hB8GzpatlEIY6fZudCcyFo6Dc3+X9tjeb+x9XcIyNAk9qhNAknjH1A==", "integrity": "sha512-aAGxCSUemZvQIibnCdvNvgaKib28I4rfrNjKbQ9cG1uBLwUsI7hVpGXgEbypCCDhLjQlDTAiJlu7rgljYUT73g==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@supabase/phoenix": "^0.4.2", "@supabase/phoenix": "^0.4.2",
@@ -1225,10 +1226,22 @@
"node": ">=20.0.0" "node": ">=20.0.0"
} }
}, },
"node_modules/@supabase/ssr": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@supabase/ssr/-/ssr-0.12.0.tgz",
"integrity": "sha512-d9XV5XzJvzzZbeAIM7fWTCUYxQJZ2Ru6ny3dJHmHGp/LIrJ+o9FpD7N9Rf/UhhWEvHXSoDe8SI32Z2ouOdMjBg==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.2"
},
"peerDependencies": {
"@supabase/supabase-js": "^2.108.0"
}
},
"node_modules/@supabase/storage-js": { "node_modules/@supabase/storage-js": {
"version": "2.107.0", "version": "2.108.2",
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.107.0.tgz", "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.108.2.tgz",
"integrity": "sha512-/X8OOVwKBn8aVKuHAGOz2yLA0d2OauqhVuy4mNtN+o7wttHOgx1/j+pqOzlsjmhOHrYykF6AJNZhs3gKZzcMUw==", "integrity": "sha512-TVZPQxXGxY2+A6yTtm77zUHsh70lBhYUEaJL8RQC+BghcX/ygiMG/rmXrNVBce30/WAeNPa8FiG8HbqlGeV05g==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"iceberg-js": "^0.8.1", "iceberg-js": "^0.8.1",
@@ -1239,16 +1252,16 @@
} }
}, },
"node_modules/@supabase/supabase-js": { "node_modules/@supabase/supabase-js": {
"version": "2.107.0", "version": "2.108.2",
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.107.0.tgz", "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.108.2.tgz",
"integrity": "sha512-ChKzdlWVweMUUhr0U79JhMmgm1haS/C5JquaiCDr70JaGARRtjjoY9rkIheXWybXxTSNzRiQs3Sk8IAg1HS3ZA==", "integrity": "sha512-hFhnPveb5JQg4a0QYicM0swT253YHMdfeRAl2BKHOlI5VAzuHxUGSr8RbwNLYNPauWOgQMS1H8sz8bvYlgwUfQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@supabase/auth-js": "2.107.0", "@supabase/auth-js": "2.108.2",
"@supabase/functions-js": "2.107.0", "@supabase/functions-js": "2.108.2",
"@supabase/postgrest-js": "2.107.0", "@supabase/postgrest-js": "2.108.2",
"@supabase/realtime-js": "2.107.0", "@supabase/realtime-js": "2.108.2",
"@supabase/storage-js": "2.107.0" "@supabase/storage-js": "2.108.2"
}, },
"engines": { "engines": {
"node": ">=20.0.0" "node": ">=20.0.0"
@@ -1789,6 +1802,19 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/core-util-is": { "node_modules/core-util-is": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",

View File

@@ -16,6 +16,7 @@
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "^5.4.0", "@hookform/resolvers": "^5.4.0",
"@supabase/ssr": "^0.12.0",
"@supabase/supabase-js": "^2.49.8", "@supabase/supabase-js": "^2.49.8",
"next": "15.3.8", "next": "15.3.8",
"react": "^19.0.0", "react": "^19.0.0",

View File

@@ -0,0 +1,58 @@
-- ============================================================
-- Script: crear primer usuario platform_admin
--
-- USO:
-- docker exec -i supabase-db psql -U postgres -d postgres \
-- -v email='admin@ejemplo.com' -v password='CAMBIAR' \
-- < scripts/setup_first_admin.sql
--
-- NOTA: las variables psql solo funcionan en SQL plano, no en DO $$.
-- Este script usa un CTE INSERT...RETURNING para encadenar ambos inserts.
--
-- CAMBIAR CONTRASEÑA LUEGO DEL PRIMER LOGIN:
-- docker exec -i supabase-db psql -U postgres -d postgres \
-- -c "UPDATE auth.users SET encrypted_password = extensions.crypt('NuevaContraseña', extensions.gen_salt('bf')) WHERE email = 'admin@ejemplo.com';"
-- ============================================================
WITH new_user AS (
INSERT INTO auth.users (
id,
instance_id,
aud,
role,
email,
encrypted_password,
email_confirmed_at,
created_at,
updated_at,
raw_app_meta_data,
raw_user_meta_data,
confirmation_token,
recovery_token,
email_change_token_new,
email_change,
is_super_admin
) VALUES (
gen_random_uuid(),
'00000000-0000-0000-0000-000000000000',
'authenticated',
'authenticated',
:'email',
extensions.crypt(:'password', extensions.gen_salt('bf')),
now(),
now(),
now(),
'{"provider":"email","providers":["email"]}',
'{}',
'',
'',
'',
'',
false
)
RETURNING id, email
)
INSERT INTO public.user_roles (user_id, app_role, org_id)
SELECT id, 'platform_admin', '10000000-0000-0000-0000-000000000002'
FROM new_user
RETURNING (SELECT email FROM new_user), user_id;

View File

@@ -0,0 +1,73 @@
-- BUG-001 — Fix filtro "Ninguna de las anteriores" en rpc_me_carrera_congelada
--
-- PROBLEMA: la migración anterior (20260626000001) dejó en el subquery interno:
-- AND a.value #>> '{}' != 'Ninguna de las anteriores'
-- El operador `#>> '{}'` aplicado sobre un ARRAY jsonb no extrae elementos;
-- convierte todo el array a su representación textual (ej. '["opcion","Ninguna..."]'),
-- por lo que la comparación nunca descarta el elemento deseado.
--
-- SOLUCIÓN: mover el filtro al WHERE del subquery expandido, donde `opt` ya existe
-- como elemento individual resultado de jsonb_array_elements_text.
CREATE OR REPLACE FUNCTION public.rpc_me_carrera_congelada(
p_survey_id uuid,
p_filters jsonb DEFAULT '{}'::jsonb
)
RETURNS jsonb
LANGUAGE plpgsql STABLE SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_f record;
v_n bigint;
v_cong bigint;
v_rows jsonb;
BEGIN
SELECT * INTO v_f FROM public._dash_parse_filters(p_filters);
SELECT
count(*),
count(*) FILTER (
WHERE CASE WHEN jsonb_typeof(a.value) = 'array'
THEN jsonb_array_length(a.value - 'Ninguna de las anteriores') > 0
ELSE false
END
)
INTO v_n, v_cong
FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad) dr
JOIN public.answers a ON a.response_id = dr.response_id
JOIN public.questions q ON q.id = a.question_id AND q.code = '7.5'
WHERE public._dash_es_cuidador(dr.response_id);
IF v_n < 5 THEN
RETURN jsonb_build_object('status','suppressed','min_n',5,'n',v_n);
END IF;
SELECT jsonb_agg(row ORDER BY cnt DESC) INTO v_rows FROM (
SELECT jsonb_build_object('opcion', opt, 'n', cnt, 'pct', ROUND(cnt::numeric / v_n * 100, 1)) AS row,
cnt
FROM (
SELECT opt, count(DISTINCT response_id) AS cnt
FROM (
SELECT jsonb_array_elements_text(a.value) AS opt,
dr.response_id
FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad) dr
JOIN public.answers a ON a.response_id = dr.response_id
JOIN public.questions q ON q.id = a.question_id AND q.code = '7.5'
WHERE public._dash_es_cuidador(dr.response_id)
AND jsonb_typeof(a.value) = 'array'
) expanded
WHERE opt != 'Ninguna de las anteriores'
GROUP BY opt
) sub
WHERE cnt >= 5
) t;
RETURN jsonb_build_object(
'status', 'ok',
'n', v_n,
'pct_carrera_congelada', ROUND(v_cong::numeric / v_n * 100, 1),
'top_opciones', COALESCE(v_rows, '[]'::jsonb)
);
END;
$$;

View File

@@ -0,0 +1,129 @@
-- ============================================================
-- Migration 004 / ETAPA_AUTH 4.2
-- Tabla user_roles + hook de custom claims para JWT
--
-- Después de aplicar esta migración, el director debe:
-- 1. Agregar en Dokploy (servicio supabase-auth):
-- GOTRUE_HOOKS_CUSTOM_ACCESS_TOKEN_ENABLED=true
-- GOTRUE_HOOKS_CUSTOM_ACCESS_TOKEN_URI=pg-functions://postgres/public/custom_access_token_hook
-- 2. Reiniciar el servicio supabase-auth (NO la app Next.js).
-- ============================================================
-- ── 1. Tabla user_roles ──────────────────────────────────────
CREATE TABLE public.user_roles (
user_id uuid PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
app_role text NOT NULL CHECK (app_role IN ('platform_admin', 'org_admin')),
org_id uuid REFERENCES public.organizations(id)
);
ALTER TABLE public.user_roles ENABLE ROW LEVEL SECURITY;
-- El usuario ve su propio rol; platform_admin ve todos
CREATE POLICY "user_roles_select_own"
ON public.user_roles
FOR SELECT
TO authenticated
USING (
user_id = auth.uid()
OR public.current_app_role() = 'platform_admin'
);
-- ── 2. Extender policies existentes para incluir platform_admin ──
-- organizations
DROP POLICY IF EXISTS "org_select_own" ON public.organizations;
CREATE POLICY "org_select_own"
ON public.organizations
FOR SELECT
TO authenticated
USING (
public.current_app_role() IN ('analyst', 'platform_admin')
OR (
public.current_app_role() = 'org_admin'
AND id = public.current_org_id()
)
);
-- surveys (no tocar surveys_select_live_anon — sigue igual)
DROP POLICY IF EXISTS "surveys_select_auth" ON public.surveys;
CREATE POLICY "surveys_select_auth"
ON public.surveys
FOR SELECT
TO authenticated
USING (
public.current_app_role() IN ('analyst', 'platform_admin')
OR (
public.current_app_role() = 'org_admin'
AND org_id = public.current_org_id()
)
);
-- questions (no tocar questions_select_live — sigue igual)
DROP POLICY IF EXISTS "questions_select_auth" ON public.questions;
CREATE POLICY "questions_select_auth"
ON public.questions
FOR SELECT
TO authenticated
USING (
public.current_app_role() IN ('analyst', 'platform_admin')
OR (
public.current_app_role() = 'org_admin'
AND EXISTS (
SELECT 1 FROM public.surveys s
WHERE s.id = survey_id
AND s.org_id = public.current_org_id()
)
)
);
-- ── 3. Nuevas policies SELECT para platform_admin (responses/answers no tenían) ──
CREATE POLICY "responses_select_platform_admin"
ON public.responses
FOR SELECT
TO authenticated
USING (public.current_app_role() IN ('analyst', 'platform_admin'));
CREATE POLICY "answers_select_platform_admin"
ON public.answers
FOR SELECT
TO authenticated
USING (public.current_app_role() IN ('analyst', 'platform_admin'));
-- ── 4. Hook de custom claims ─────────────────────────────────
-- Inyecta app_role y org_id en el JWT en cada emisión de token.
-- SECURITY DEFINER + search_path garantiza que bypasea RLS para leer user_roles.
CREATE OR REPLACE FUNCTION public.custom_access_token_hook(event jsonb)
RETURNS jsonb
LANGUAGE plpgsql
STABLE
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
claims jsonb;
v_role text;
v_org uuid;
BEGIN
claims := event -> 'claims';
SELECT app_role, org_id
INTO v_role, v_org
FROM public.user_roles
WHERE user_id = (event ->> 'user_id')::uuid;
IF v_role IS NOT NULL THEN
claims := jsonb_set(claims, '{app_role}', to_jsonb(v_role));
IF v_org IS NOT NULL THEN
claims := jsonb_set(claims, '{org_id}', to_jsonb(v_org::text));
END IF;
END IF;
RETURN jsonb_set(event, '{claims}', claims);
END;
$$;
-- GoTrue necesita EXECUTE para llamar la función y SELECT para que el SECURITY DEFINER
-- pueda leer user_roles internamente (aunque SECURITY DEFINER corre como postgres,
-- el grant explicito documenta la intención y protege contra cambios de ownership).
GRANT EXECUTE ON FUNCTION public.custom_access_token_hook(jsonb) TO supabase_auth_admin;
GRANT SELECT ON public.user_roles TO supabase_auth_admin;

View File

@@ -0,0 +1,190 @@
-- ============================================================
-- Migration: audit_log append-only — SEC-002 (Sprint 5, compliance)
--
-- Loggea cambios de configuración crítica:
-- - user_roles: altas/bajas/cambios de app_role u org_id (quién es admin)
-- - surveys.status: activación/desactivación de encuestas
-- - DDL en schema public: toda migración aplicada (CREATE/ALTER/DROP),
-- vía event trigger ddl_command_end. Confirmado viable en este Postgres
-- self-hosted sin extensiones adicionales: el rol `postgres` tiene
-- rolsuper=false pero pudo crear el event trigger sin error
-- (diagnóstico ETAPA_AUDIT_LOG_PROMPT.md §4.1, 2026-06-27).
--
-- NO loggea acceso a datos sensibles (is_sensitive=true): hoy ningún
-- camino de código (ni /admin/responses ni las RPCs rpc_me_*) lee esas
-- columnas (COMPLIANCE.md §3.2). Deferido a cuando ese código exista —
-- ese mismo cambio debe agregar su propio log de acceso, no esta migración.
--
-- GAP CONOCIDO (SEC-003, aceptado, NO resuelto aquí): un operador con
-- acceso directo a `psql -U postgres` en el VPS puede TRUNCATE/DROP esta
-- tabla, o saltear los triggers con `SET session_replication_role =
-- replica` / `ALTER TABLE ... DISABLE TRIGGER ALL`. El append-only de
-- abajo cierra la escritura vía API (anon/authenticated/PostgREST) y
-- bloquea UPDATE/DELETE para cualquier caller normal (incluido
-- service_role, que bypasa RLS pero no bypasa triggers) — no protege
-- contra un operador con shell en la base.
-- ============================================================
CREATE TABLE public.audit_log (
id bigserial PRIMARY KEY,
occurred_at timestamptz NOT NULL DEFAULT now(),
actor_role text,
actor_user_id uuid,
action text NOT NULL,
target_table text NOT NULL,
target_id text,
detail jsonb
);
COMMENT ON TABLE public.audit_log IS
'Append-only. INSERT solo vía funciones SECURITY DEFINER de los triggers de esta migración. UPDATE/DELETE bloqueados por trigger audit_log_block_mutation. Gap conocido: no protege contra TRUNCATE/DROP por superusuario directo en el VPS (SEC-003).';
COMMENT ON COLUMN public.audit_log.actor_role IS
'current_app_role() del que disparó el cambio (user_roles/surveys), o session_user para eventos DDL.';
ALTER TABLE public.audit_log ENABLE ROW LEVEL SECURITY;
-- Solo platform_admin puede leer. No hay policies de INSERT/UPDATE/DELETE
-- para ningún rol vía API — el único camino de escritura son las
-- funciones SECURITY DEFINER (corren como propietario de la tabla,
-- `postgres`, que por default de Postgres bypasa RLS sobre objetos
-- propios sin necesitar FORCE ROW LEVEL SECURITY).
CREATE POLICY "audit_log_select_platform_admin"
ON public.audit_log FOR SELECT
TO authenticated
USING (public.current_app_role() = 'platform_admin');
REVOKE INSERT, UPDATE, DELETE ON public.audit_log FROM PUBLIC, anon, authenticated;
GRANT SELECT ON public.audit_log TO authenticated;
-- ============================================================
-- Barrera 2: append-only enforcement a nivel de trigger.
-- Se aplica a CUALQUIER caller (incluido service_role) salvo que alguien
-- deshabilite triggers con privilegios de superusuario real en el VPS
-- (gap documentado arriba, SEC-003).
-- ============================================================
CREATE OR REPLACE FUNCTION public.audit_log_block_mutation()
RETURNS trigger
LANGUAGE plpgsql
SET search_path = public
AS $$
BEGIN
RAISE EXCEPTION 'audit_log es append-only: % no permitido', TG_OP;
END;
$$;
REVOKE ALL ON FUNCTION public.audit_log_block_mutation() FROM PUBLIC;
CREATE TRIGGER audit_log_no_update
BEFORE UPDATE ON public.audit_log
FOR EACH ROW EXECUTE FUNCTION public.audit_log_block_mutation();
CREATE TRIGGER audit_log_no_delete
BEFORE DELETE ON public.audit_log
FOR EACH ROW EXECUTE FUNCTION public.audit_log_block_mutation();
-- ============================================================
-- Trigger 1: cambios en user_roles (altas/bajas/cambios de acceso)
-- Función disparada automáticamente por el trigger — no requiere GRANT
-- EXECUTE a ningún rol (el mecanismo de triggers no pasa por una llamada
-- SQL directa del caller).
-- ============================================================
CREATE OR REPLACE FUNCTION public.log_user_roles_change()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
INSERT INTO public.audit_log (actor_role, actor_user_id, action, target_table, target_id, detail)
VALUES (
public.current_app_role(),
auth.uid(),
'user_roles_' || lower(TG_OP),
'user_roles',
COALESCE(NEW.user_id, OLD.user_id)::text,
jsonb_build_object(
'old', CASE WHEN TG_OP IN ('UPDATE','DELETE') THEN to_jsonb(OLD) ELSE NULL END,
'new', CASE WHEN TG_OP IN ('UPDATE','INSERT') THEN to_jsonb(NEW) ELSE NULL END
)
);
RETURN COALESCE(NEW, OLD);
END;
$$;
REVOKE ALL ON FUNCTION public.log_user_roles_change() FROM PUBLIC;
CREATE TRIGGER user_roles_audit
AFTER INSERT OR UPDATE OR DELETE ON public.user_roles
FOR EACH ROW EXECUTE FUNCTION public.log_user_roles_change();
-- ============================================================
-- Trigger 2: cambios de status en surveys (activación/desactivación)
-- ============================================================
CREATE OR REPLACE FUNCTION public.log_survey_status_change()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
IF NEW.status IS DISTINCT FROM OLD.status THEN
INSERT INTO public.audit_log (actor_role, actor_user_id, action, target_table, target_id, detail)
VALUES (
public.current_app_role(),
auth.uid(),
'survey_status_change',
'surveys',
NEW.id::text,
jsonb_build_object('old_status', OLD.status, 'new_status', NEW.status, 'title', NEW.title)
);
END IF;
RETURN NEW;
END;
$$;
REVOKE ALL ON FUNCTION public.log_survey_status_change() FROM PUBLIC;
CREATE TRIGGER surveys_status_audit
AFTER UPDATE ON public.surveys
FOR EACH ROW EXECUTE FUNCTION public.log_survey_status_change();
-- ============================================================
-- Trigger 3 (Prioridad 2, confirmado viable — ver §4.1 del prompt de etapa):
-- DDL en schema public → loggea toda migración aplicada.
-- ============================================================
CREATE OR REPLACE FUNCTION public.log_ddl_command()
RETURNS event_trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
obj record;
BEGIN
FOR obj IN SELECT * FROM pg_event_trigger_ddl_commands() LOOP
IF obj.schema_name = 'public' OR obj.schema_name IS NULL THEN
INSERT INTO public.audit_log (actor_role, actor_user_id, action, target_table, target_id, detail)
VALUES (
session_user,
NULL,
'ddl_' || obj.command_tag,
COALESCE(obj.object_identity, obj.schema_name, 'unknown'),
obj.objid::text,
jsonb_build_object('command_tag', obj.command_tag, 'object_type', obj.object_type)
);
END IF;
END LOOP;
END;
$$;
REVOKE ALL ON FUNCTION public.log_ddl_command() FROM PUBLIC;
CREATE EVENT TRIGGER audit_ddl_public
ON ddl_command_end
EXECUTE FUNCTION public.log_ddl_command();
-- ============================================================
-- Fin de migración. NO aplicar en el VPS sin aprobación explícita del
-- director — ver ETAPA_AUDIT_LOG_PROMPT.md §4.3.
-- ============================================================