docs(plan): fases 11–14 — tags, section visibility, server admin, PWA hardening
Adds four planned-phase docs covering the next post-MVP roadmap: - Fase 11 — item tags (collective-scoped) + drag-reorder UI for the existing `shopping_items.sort_order` column. Migration 021. - Fase 12 — section visibility via JSONB `feature_flags` on `users` and `collectives`, with a `section_enabled()` SQL helper that resolves precedence (collective > user > default ON). Migration 022. - Fase 13 — `/admin` area with a global `server_admins` role, append-only `admin_actions` audit log, RPCs for collective CRUD + member removal, and server-level section-visibility defaults that sit above Fase 12. Migrations 023+024. Depends on Fase 12. - Fase 14 — PWA hardening: `onNeedRefresh` update toast, explicit offline indicator + sync queue badge + conflict surface, `__APP_VERSION__` and `__APP_COMMIT__` baked at build with a chip in `/settings`. No migration. Depends on Fase 9.3 (`sync_conflicts` wiring). Also bumps CLAUDE.md project status with the planned-fases pointer, and brings README.md status + index tables in line with Fase 7, 8, 9, 10 (previously missing) plus the four new ones. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
159
plan/fase-13-server-admin.md
Normal file
159
plan/fase-13-server-admin.md
Normal file
@@ -0,0 +1,159 @@
|
||||
### Fase 13 — Server administration
|
||||
|
||||
**Estado: 📋 Planificada. Mayor salto de scope hasta ahora — introduce un rol global ortogonal a los roles de colectivo, un área `/admin` nueva, RPCs con privilegios elevados, y un audit log. Requiere Fase 12 para los toggles de visibilidad a nivel servidor.**
|
||||
|
||||
**Objetivo: dar al operador del servidor herramientas para gestionar la salud de la instancia sin tocar SQL en producción — listar y borrar colectivos huérfanos, expulsar miembros problemáticos, fijar visibilidad por defecto de secciones a nivel servidor, y dejar trazabilidad de cada acción.**
|
||||
|
||||
**Decisiones clave:**
|
||||
- **Rol global aparte del modelo de colectivo.** Un `server_admin` puede no pertenecer a ningún colectivo. Tabla `server_admins (user_id pk)`. No flag en `users` para evitar tener que regrabar JWT al promote/demote — la verificación es una query a esta tabla.
|
||||
- **Bootstrap por seed o ENV.** En arranque, si la tabla está vacía y `SERVER_ADMIN_EMAIL` está seteado, promover ese email a admin (idempotente).
|
||||
- **RPCs SECURITY DEFINER con check explícito** `if not is_server_admin(auth.uid()) then raise exception 'forbidden'`. No se exponen tablas con RLS open para admins.
|
||||
- **Audit log inmutable**. Tabla `admin_actions` append-only (no UPDATE/DELETE policy). Cada RPC admin escribe una fila.
|
||||
- **UI separada** en `/admin/*`. Layout propio. No se cuela con el shell del usuario normal (cambio visual evidente — banner rojo).
|
||||
|
||||
---
|
||||
|
||||
#### 13.1 Modelo: rol + audit log
|
||||
|
||||
**Fichero:** `supabase/migrations/023_server_admin.sql`.
|
||||
|
||||
```sql
|
||||
create table public.server_admins (
|
||||
user_id uuid primary key references public.users(id) on delete cascade,
|
||||
granted_by uuid null references public.users(id) on delete set null,
|
||||
granted_at timestamptz not null default now()
|
||||
);
|
||||
alter table public.server_admins enable row level security;
|
||||
|
||||
create policy "select_self_or_admin" on public.server_admins
|
||||
for select using (
|
||||
user_id = auth.uid()
|
||||
or exists (select 1 from public.server_admins where user_id = auth.uid())
|
||||
);
|
||||
-- Sin INSERT/UPDATE/DELETE policy: solo SECURITY DEFINER RPCs pueden tocarla.
|
||||
|
||||
create or replace function public.is_server_admin(p_user uuid default auth.uid())
|
||||
returns boolean language sql stable security definer
|
||||
set search_path = public, auth
|
||||
as $$ select exists (select 1 from public.server_admins where user_id = p_user) $$;
|
||||
|
||||
create table public.admin_actions (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
actor_id uuid not null references public.users(id) on delete restrict,
|
||||
action text not null,
|
||||
target_type text not null,
|
||||
target_id uuid null,
|
||||
payload jsonb not null default '{}'::jsonb,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
alter table public.admin_actions enable row level security;
|
||||
|
||||
create policy "select_admin_only" on public.admin_actions
|
||||
for select using (public.is_server_admin());
|
||||
-- Append-only: ningún UPDATE/DELETE policy. Solo INSERT desde RPCs SECURITY DEFINER.
|
||||
|
||||
create index admin_actions_actor_idx on public.admin_actions (actor_id, created_at desc);
|
||||
create index admin_actions_target_idx on public.admin_actions (target_type, target_id);
|
||||
```
|
||||
|
||||
**Tareas:**
|
||||
|
||||
- [ ] **13.1.1** Migración 023.
|
||||
- [ ] **13.1.2** Bootstrap: script `infra/db-init/10-server-admin-seed.sh` que lee `SERVER_ADMIN_EMAIL` y, si la tabla está vacía, inserta la fila correspondiente vía join a `auth.users`. Idempotente.
|
||||
- [ ] **13.1.3** Añadir `SERVER_ADMIN_EMAIL` a `.env.erosi.example` con comentario explicativo.
|
||||
- [ ] **13.1.4** Tests pgTAP `supabase/tests/013_server_admin.sql`: `is_server_admin()` true/false; non-admin no puede SELECT `server_admins` ajeno; non-admin no puede ver `admin_actions`.
|
||||
|
||||
---
|
||||
|
||||
#### 13.2 RPCs admin
|
||||
|
||||
**Fichero:** `supabase/migrations/024_admin_rpcs.sql`.
|
||||
|
||||
Funciones (todas SECURITY DEFINER con check `if not is_server_admin() then raise exception 'forbidden' using errcode = 'P0001'`):
|
||||
|
||||
```sql
|
||||
-- Gestión de admins (solo otro admin puede promote/demote; el último admin no puede demoteárse).
|
||||
create function public.grant_server_admin(p_user uuid) returns void ...
|
||||
create function public.revoke_server_admin(p_user uuid) returns void ...
|
||||
|
||||
-- Colectivos
|
||||
create function public.admin_list_collectives(p_search text default null, p_limit int default 50)
|
||||
returns table (id uuid, name text, member_count int, created_at timestamptz, deleted_at timestamptz) ...
|
||||
create function public.admin_soft_delete_collective(p_collective_id uuid, p_reason text) returns void ...
|
||||
create function public.admin_restore_collective(p_collective_id uuid) returns void ...
|
||||
create function public.admin_hard_delete_collective(p_collective_id uuid) returns void ...
|
||||
-- Solo si deleted_at not null y han pasado >= 30 días, o force flag explícito.
|
||||
|
||||
-- Miembros
|
||||
create function public.admin_remove_member(p_collective_id uuid, p_user uuid, p_reason text) returns void ...
|
||||
|
||||
-- Section visibility por defecto a nivel servidor (Fase 12 dependency)
|
||||
create function public.admin_set_default_section(p_section text, p_enabled boolean) returns void ...
|
||||
-- Escribe en una tabla nueva server_settings (key, value jsonb) — se crea aquí.
|
||||
```
|
||||
|
||||
**Tareas:**
|
||||
|
||||
- [ ] **13.2.1** Migración 024 con todas las RPCs + tabla `server_settings (key text primary key, value jsonb)`.
|
||||
- [ ] **13.2.2** Cada RPC: chequeo de admin → inserción en `admin_actions` → ejecución. Wrap en transacción.
|
||||
- [ ] **13.2.3** Guard "último admin": `revoke_server_admin` falla si el target es el único en `server_admins`.
|
||||
- [ ] **13.2.4** Integrar `server_settings.default_sections` en `public.section_enabled(...)` de Fase 12 — añade una capa por encima del colectivo: precedencia final **server → collective → user → default true**.
|
||||
- [ ] **13.2.5** Tests pgTAP `supabase/tests/014_admin_rpcs.sql`: cada RPC denegada para non-admin; soft-delete escribe audit; hard-delete bloqueado antes de los 30 días sin force; promote/demote audit; último admin no puede demoteárse.
|
||||
|
||||
---
|
||||
|
||||
#### 13.3 UI `/admin/*`
|
||||
|
||||
**Ficheros:** nueva ruta `apps/web/src/routes/(admin)/`. Layout propio.
|
||||
|
||||
- [ ] **13.3.1** `(admin)/+layout.ts`: `load` redirige a `/` si `is_server_admin()` (vía RPC) devuelve false. Sin SSR — `ssr: false`.
|
||||
- [ ] **13.3.2** `(admin)/+layout.svelte`: banner rojo `bg-red-600 text-white` con texto "Admin mode — actions are logged". Sidebar simple: Colectivos, Admins, Audit log, Servidor.
|
||||
- [ ] **13.3.3** `/admin/collectives` — tabla paginada con search por nombre, columnas: nombre, miembros, creado, estado (activo / soft-deleted). Acciones por fila: ver, soft-delete (modal con reason obligatorio), restore, hard-delete (modal de confirmación + checkbox "Entiendo que es irreversible").
|
||||
- [ ] **13.3.4** `/admin/collectives/[id]` — detalle: miembros con rol, botón "Expulsar" por miembro (modal con reason). Sección "Acciones recientes" filtrando `admin_actions` por este `target_id`.
|
||||
- [ ] **13.3.5** `/admin/admins` — lista de `server_admins`. Botón "Promote user" abre modal con email lookup (`auth.users where email = ...`). Botón "Revoke" por fila (deshabilitado si es uno mismo o si es el único).
|
||||
- [ ] **13.3.6** `/admin/audit` — feed reverse-chronological de `admin_actions`, filtros por actor, acción, fecha. Paginación a 50.
|
||||
- [ ] **13.3.7** `/admin/server` — defaults de sección (Fase 12): 4 toggles + servicio info (versión, uptime — usar `select pg_postmaster_start_time(), version()` vía RPC).
|
||||
|
||||
---
|
||||
|
||||
#### 13.4 Acceso al menú
|
||||
|
||||
- [ ] **13.4.1** Si `$isServerAdmin` (store derivado de un `is_server_admin()` cacheado al login), añadir entrada "Admin" en el menú de usuario del sidebar/drawer (separador + ícono `Shield` rojo).
|
||||
- [ ] **13.4.2** Refrescar el flag al `INITIAL_SESSION` / `SIGNED_IN` / `TOKEN_REFRESHED`.
|
||||
|
||||
---
|
||||
|
||||
#### 13.5 Tests
|
||||
|
||||
- [ ] **13.5.1** Vitest integración `tests/integration/server-admin.test.ts`: precedencia de section-visibility con la capa server activa; RPCs denegadas para no-admin; audit log se escribe; hard-delete cascade no rompe FK fuera del colectivo.
|
||||
- [ ] **13.5.2** Playwright `tests/e2e/admin.test.ts` (nuevo helper `loginAsAdmin`):
|
||||
- SA-01 admin promovido vía seed accede a `/admin`; usuario normal redirigido a `/`.
|
||||
- SA-02 admin soft-deletea un colectivo → miembros no pueden entrar (redirect a `/onboarding`); admin restore lo recupera.
|
||||
- SA-03 admin apaga "Notes" a nivel servidor → todos los colectivos lo ven OFF aunque el admin de colectivo lo ponga ON localmente.
|
||||
- SA-04 admin promueve a otro usuario; el segundo accede a `/admin`; el primero intenta revocarse a sí mismo siendo el único restante → bloqueado.
|
||||
|
||||
---
|
||||
|
||||
#### 13.Z Verificación + cierre
|
||||
|
||||
- [ ] **13.Z.1** `just test-all` verde. Suma esperada: +6 pgTAP + ~4 vitest + 4 playwright.
|
||||
- [ ] **13.Z.2** Revisión manual de seguridad: `select * from pg_proc where proname like 'admin\_%' and prosecdef` debe coincidir con la lista esperada de RPCs SECURITY DEFINER (todas y solo las nuestras).
|
||||
- [ ] **13.Z.3** Smoke en prod: crear un server_admin vía bootstrap, listar colectivos, ver audit log. No tocar datos reales.
|
||||
- [ ] **13.Z.4** Documentar en `docs/history/fase-13-server-admin.md` + sección "Server administration" en `docs/deployment.md` (cómo promover el primer admin via `SERVER_ADMIN_EMAIL`).
|
||||
|
||||
---
|
||||
|
||||
### Riesgos / notas
|
||||
|
||||
- **Hard delete cascade**. Borrar un colectivo borra `shopping_lists` → `shopping_items` → `shopping_item_tags` → (Fase 11) `item_tags`. Todas las FK ya tienen `on delete cascade`; verificar en `EXPLAIN`. Confirmar también que `users` no se ve afectado.
|
||||
- **JWT no contiene el flag admin** (el rol se chequea por query, no por claim). Compromiso: una query extra al login + cada vez que el store se hidrata. Beneficio: promote/revoke instantáneo sin re-login. Si esto se vuelve caliente, mover a custom claim vía hook `auth.users` → `raw_app_meta_data`.
|
||||
- **Audit append-only sin policy DELETE** funciona en runtime, pero `postgres` superuser puede borrar. Aceptable: el modelo de amenaza es "admin malicioso del producto", no "operador con shell en el servidor".
|
||||
- **Bootstrap idempotente** importante — si ya hay admins, el script no debe degradar nada. Test con `pgTAP`: segunda invocación con `SERVER_ADMIN_EMAIL` distinto no toca filas previas.
|
||||
|
||||
### Scope explícito fuera
|
||||
|
||||
- 2FA obligatorio para admins. Bonito, no MVP.
|
||||
- Roles intermedios (read-only admin). Solo `server_admin` binario.
|
||||
- Webhooks o notificaciones de acciones admin. Solo audit table.
|
||||
- Importar/exportar colectivos como JSON. Solo CRUD destructivo + creación de usuario normal.
|
||||
- Admin sobre `auth.users` directamente. Solo a nivel `public` (colectivos, miembros, settings).
|
||||
Reference in New Issue
Block a user