Compare commits
7 Commits
v3.0.0
...
b3f039fbd5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3f039fbd5 | ||
|
|
430bf7a5da | ||
|
|
10bb7299a2 | ||
|
|
dda4b5320a | ||
|
|
44d1f4b9d9 | ||
|
|
a484ba7849 | ||
|
|
ddaba6866a |
17
TECH_DEBT.md
17
TECH_DEBT.md
@@ -77,14 +77,15 @@
|
||||
|
||||
## [TECH-DEBT-008] /admin/responses sin autenticación
|
||||
|
||||
- **Estado:** ABIERTO.
|
||||
- **Descripción:** la vista de control `/admin/responses` no tiene auth. Por diseño
|
||||
explícito, excluye preguntas `is_sensitive=true` (14 preguntas de salud/
|
||||
discapacidad) y `text_long`. Para mostrar esas preguntas en la vista, primero
|
||||
implementar un gate de autenticación para staff (Basic Auth con env var como
|
||||
mínimo viable).
|
||||
- **Impacto actual:** ninguno en producción — la URL no está publicada.
|
||||
- **Bloqueante para:** ampliar visibilidad de datos en la vista de control.
|
||||
- **Estado:** RESUELTO (27 jun 2026, ETAPA_AUTH).
|
||||
- **Descripción:** la vista de control `/admin/responses` no tenía auth. Se implementó
|
||||
login con Supabase Auth (`@supabase/ssr`), `middleware.ts` protegiendo
|
||||
`/admin/:path*` y `/dashboard/:path*` con redirect a `/login` sin sesión, y la página
|
||||
migrada de `service_role` a la sesión del usuario (`anon` key + RLS). Sigue
|
||||
excluyendo preguntas `is_sensitive=true` y `text_long` — ese diseño no cambió.
|
||||
- **Nota:** la vista no distingue roles dentro de staff todavía (todo usuario con fila
|
||||
en `user_roles` ve lo mismo). Diferenciar `platform_admin` de `org_admin` ahí sería
|
||||
una etapa nueva, no parte de este cierre.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 { 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'
|
||||
|
||||
const MAX_SUBMISSIONS = 500
|
||||
@@ -36,10 +36,28 @@ interface ResponseRow {
|
||||
answers: AnswerRow[]
|
||||
}
|
||||
|
||||
function getSupabaseAdmin() {
|
||||
const url = process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://placeholder.supabase.co'
|
||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY || 'placeholder'
|
||||
return createClient(url, serviceKey, { auth: { persistSession: false } })
|
||||
async function getSupabaseSession() {
|
||||
const cookieStore = await cookies()
|
||||
return createServerClient(
|
||||
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 {
|
||||
@@ -76,7 +94,7 @@ export default async function AdminResponsesPage({
|
||||
searchParams: Promise<{ survey_id?: string }>
|
||||
}) {
|
||||
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Á,
|
||||
// antes de tocar la tabla answers. Sus valores nunca se consultan.
|
||||
@@ -214,7 +232,10 @@ export default async function AdminResponsesPage({
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
<a href="/admin/responses" className="admin-refresh">Actualizar</a>
|
||||
<div className="admin-header-actions">
|
||||
<a href="/admin/responses" className="admin-refresh">Actualizar</a>
|
||||
<SignOutButton />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="admin-table-wrapper">
|
||||
|
||||
24
app/admin/sign-out-button.tsx
Normal file
24
app/admin/sign-out-button.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -964,7 +964,6 @@ body {
|
||||
/* ── Card destacada (estrella) ── */
|
||||
.db-widget-star {
|
||||
border-color: var(--color-re-teal);
|
||||
background: linear-gradient(135deg, #dff4f5, #ffffff);
|
||||
}
|
||||
.db-widget-star .db-widget-title { color: var(--color-re-teal); }
|
||||
|
||||
@@ -1060,3 +1059,84 @@ body {
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::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
103
app/login/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
# 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
|
||||
> Metodología: Maria Mersan · Contacto Fundación: Valeria Arce
|
||||
@@ -7,17 +7,14 @@
|
||||
---
|
||||
|
||||
## 1. Versionado y ramas
|
||||
- **Producción:** 2.1.0 (formulario lineal) — los 3 surveys en `draft`
|
||||
- **Desarrollo:** 3.0.0 (wizard + dashboard nuevo) en `main`, NO desplegado aún
|
||||
- **Producción:** 3.0.0 (wizard + dashboard tres miradas) — tag `v3.0.0` pusheado, los 3 surveys
|
||||
en `live`. Release v3 completado (etapa 4I).
|
||||
- **Preservación:** `v2.1.0-stable` + tag `v2.1.0`
|
||||
- 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 |
|
||||
|---|---|
|
||||
@@ -28,26 +25,41 @@ Se reactivan a `live` al desplegar v3 completo.
|
||||
| 4D wizard renderer (layout estable) | ✅ |
|
||||
| 4E instancias (emergente del motor) | ✅ |
|
||||
| 4G submit + validación server-side (RPC transaccional) | ✅ verificado vs Postgres real |
|
||||
| 4F autocomplete barrios (UX) | ⏳ pendiente |
|
||||
| 4I validación e2e + tag v3.0.0 + desplegar + reactivar surveys | ⏳ pendiente |
|
||||
| 4F autocomplete barrios (UX) | ✅ |
|
||||
| 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
|
||||
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,
|
||||
dashboard nuevo de tres miradas, fundamentado en marcos reales (OIT 5R, CEPAL, PNCUPA Paraguay).
|
||||
|
||||
### Estado
|
||||
- **Mirada Empresa:** ✅ RPCs implementadas y VERIFICADAS contra 50 respuestas reales.
|
||||
E1-E4 exactos, estrella "Talento en riesgo" con umbral parametrizable, E9 segmentación
|
||||
con k≥5. Migraciones `20260625000001` (fix helper) + `20260625000002` (11 RPCs). Commiteado.
|
||||
Widgets del frontend: PENDIENTE (etapa siguiente).
|
||||
- **Mirada Empresa:** ✅ RPCs + widgets de frontend completos y en producción. E1-E10 + estrella
|
||||
"Talento en riesgo", k≥5 en segmentación. Tema claro aplicado.
|
||||
- **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/)
|
||||
- `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
|
||||
|
||||
~~TECH-DEBT-008 (`/admin/responses` sin autenticación)~~ — **resuelto** por ETAPA_AUTH (27 jun).
|
||||
|
||||
| ID | Descripción | Prioridad |
|
||||
|---|---|---|
|
||||
| TECH-DEBT-006 | Backup remoto S3 | 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 |
|
||||
| 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-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 |
|
||||
| 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
|
||||
@@ -87,14 +108,19 @@ dashboard nuevo de tres miradas, fundamentado en marcos reales (OIT 5R, CEPAL, P
|
||||
---
|
||||
|
||||
## 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
43
middleware.ts
Normal 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
78
package-lock.json
generated
@@ -9,6 +9,7 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.4.0",
|
||||
"@supabase/ssr": "^0.12.0",
|
||||
"@supabase/supabase-js": "^2.49.8",
|
||||
"next": "15.3.8",
|
||||
"react": "^19.0.0",
|
||||
@@ -1047,9 +1048,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@supabase/auth-js": {
|
||||
"version": "2.107.0",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.107.0.tgz",
|
||||
"integrity": "sha512-XA7x+WIeIvuC3GTZ2ey67QcBbGw4n+o5B7M+dMm9KT1lL3wX1B52DfEWW00WuPt/LnniJLLIn1WIm9YPtuxzKQ==",
|
||||
"version": "2.108.2",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.108.2.tgz",
|
||||
"integrity": "sha512-tNaQmBgodDZwgB40mRwVbxFy8IDYwjdpcZ0BYrWiwlULCSQoJj4QoG4zgJT7QRPXcqipefNOzvO/qAu4dF98ag==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "2.8.1"
|
||||
@@ -1183,9 +1184,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@supabase/functions-js": {
|
||||
"version": "2.107.0",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.107.0.tgz",
|
||||
"integrity": "sha512-iMtRUmEj1KOgQd/a3MR4hnBlPnZc62DW8+z8aPpnzbxWkexEZUVL2fSgvvp15gqFg1V55e2yMGqgK+yhSQxp5w==",
|
||||
"version": "2.108.2",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.108.2.tgz",
|
||||
"integrity": "sha512-RNUX8EiBy3iLwAX19jtRzLyePnl11/fHcgwDHLnpKcDSXt/5qBnh3LUwAtIjT21Q66QsmNUR2esrHziLCpNubw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "2.8.1"
|
||||
@@ -1195,15 +1196,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/phoenix": {
|
||||
"version": "0.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.2.tgz",
|
||||
"integrity": "sha512-YSAGnmDAfuleFCVt3CeurQZAhxRfXWeZIIkwp7NhYzQ1UwW6ePSnzsFAiUm/mbCkfoCf70QQHKW/K6RKh52a4A==",
|
||||
"version": "0.4.4",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.4.tgz",
|
||||
"integrity": "sha512-Gt0pqoXuIqX/8dvG0OKp/wMCobXNH3klNbUPBNyOfN0YA1IswrM3HyWFMOPk1Jy+BRaIyDPcFx4jLBwHNmlyfQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@supabase/postgrest-js": {
|
||||
"version": "2.107.0",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.107.0.tgz",
|
||||
"integrity": "sha512-7ARs47/tyIjX7T0Ive20d4NY8zQYXsP5/P07jJWxffSIM2gpnSnGRnL/Fe15GPbdjsW2sTYeckHcyaoKbM6yWQ==",
|
||||
"version": "2.108.2",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.108.2.tgz",
|
||||
"integrity": "sha512-GQ28/Y8hk3CFmkb3kXH1h/AQx6JIYSQfO0CJMRVBcEKZoNy6C45cXAZ4fcJvRC5Id0cs6xnkUV0+c0rIocigsw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "2.8.1"
|
||||
@@ -1213,9 +1214,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/realtime-js": {
|
||||
"version": "2.107.0",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.107.0.tgz",
|
||||
"integrity": "sha512-cF2KYdR3JIn9YlWGeluY9S0G+otqTdL6hB8GzpatlEIY6fZudCcyFo6Dc3+X9tjeb+x9XcIyNAk9qhNAknjH1A==",
|
||||
"version": "2.108.2",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.108.2.tgz",
|
||||
"integrity": "sha512-aAGxCSUemZvQIibnCdvNvgaKib28I4rfrNjKbQ9cG1uBLwUsI7hVpGXgEbypCCDhLjQlDTAiJlu7rgljYUT73g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@supabase/phoenix": "^0.4.2",
|
||||
@@ -1225,10 +1226,22 @@
|
||||
"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": {
|
||||
"version": "2.107.0",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.107.0.tgz",
|
||||
"integrity": "sha512-/X8OOVwKBn8aVKuHAGOz2yLA0d2OauqhVuy4mNtN+o7wttHOgx1/j+pqOzlsjmhOHrYykF6AJNZhs3gKZzcMUw==",
|
||||
"version": "2.108.2",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.108.2.tgz",
|
||||
"integrity": "sha512-TVZPQxXGxY2+A6yTtm77zUHsh70lBhYUEaJL8RQC+BghcX/ygiMG/rmXrNVBce30/WAeNPa8FiG8HbqlGeV05g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"iceberg-js": "^0.8.1",
|
||||
@@ -1239,16 +1252,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/supabase-js": {
|
||||
"version": "2.107.0",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.107.0.tgz",
|
||||
"integrity": "sha512-ChKzdlWVweMUUhr0U79JhMmgm1haS/C5JquaiCDr70JaGARRtjjoY9rkIheXWybXxTSNzRiQs3Sk8IAg1HS3ZA==",
|
||||
"version": "2.108.2",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.108.2.tgz",
|
||||
"integrity": "sha512-hFhnPveb5JQg4a0QYicM0swT253YHMdfeRAl2BKHOlI5VAzuHxUGSr8RbwNLYNPauWOgQMS1H8sz8bvYlgwUfQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@supabase/auth-js": "2.107.0",
|
||||
"@supabase/functions-js": "2.107.0",
|
||||
"@supabase/postgrest-js": "2.107.0",
|
||||
"@supabase/realtime-js": "2.107.0",
|
||||
"@supabase/storage-js": "2.107.0"
|
||||
"@supabase/auth-js": "2.108.2",
|
||||
"@supabase/functions-js": "2.108.2",
|
||||
"@supabase/postgrest-js": "2.108.2",
|
||||
"@supabase/realtime-js": "2.108.2",
|
||||
"@supabase/storage-js": "2.108.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
@@ -1789,6 +1802,19 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.4.0",
|
||||
"@supabase/ssr": "^0.12.0",
|
||||
"@supabase/supabase-js": "^2.49.8",
|
||||
"next": "15.3.8",
|
||||
"react": "^19.0.0",
|
||||
|
||||
58
scripts/setup_first_admin.sql
Normal file
58
scripts/setup_first_admin.sql
Normal 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;
|
||||
@@ -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;
|
||||
$$;
|
||||
129
supabase/migrations/20260627000001_auth_user_roles.sql
Normal file
129
supabase/migrations/20260627000001_auth_user_roles.sql
Normal 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;
|
||||
190
supabase/migrations/20260627000002_audit_log.sql
Normal file
190
supabase/migrations/20260627000002_audit_log.sql
Normal 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.
|
||||
-- ============================================================
|
||||
Reference in New Issue
Block a user