Compare commits

...

3 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
5 changed files with 299 additions and 41 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

@@ -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); }
@@ -1093,6 +1092,26 @@ body {
color: var(--color-text); color: var(--color-text);
} }
.login-error { margin: 0; } .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 { .login-submit {
margin-top: 8px; margin-top: 8px;
padding: 10px 20px; padding: 10px 20px;

View File

@@ -7,6 +7,7 @@ import { useRouter } from 'next/navigation'
export default function LoginPage() { export default function LoginPage() {
const [isPending, setIsPending] = useState(false) const [isPending, setIsPending] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [showPassword, setShowPassword] = useState(false)
const router = useRouter() const router = useRouter()
const supabase = createBrowserClient( const supabase = createBrowserClient(
@@ -54,15 +55,36 @@ export default function LoginPage() {
</div> </div>
<div className="login-field"> <div className="login-field">
<label htmlFor="password" className="login-label">Contraseña</label> <label htmlFor="password" className="login-label">Contraseña</label>
<div className="login-password-wrapper">
<input <input
id="password" id="password"
name="password" name="password"
type="password" type={showPassword ? 'text' : 'password'}
autoComplete="current-password" autoComplete="current-password"
required required
disabled={isPending} disabled={isPending}
className="text-short-input" 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> </div>
{error && ( {error && (
<p role="alert" className="field-error login-error">{error}</p> <p role="alert" className="field-error login-error">{error}</p>

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).
--- ---

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.
-- ============================================================