d43ffae3ae
- reports/0001-2026-06-07-unibus-grafana-monitoring.md - reports/0008-2026-06-07-unibus-admin-users-wired.md - reports/0008-2026-06-07-unibus-decentralization-audit.md - reports/0009-2026-06-07-unibus-cluster-hardening.md - reports/0010-2026-06-07-unibus-android-native.md - reports/0011-2026-06-07-unibus-cluster-deploy.md - reports/0012-2026-06-07-unibus-deploy-gaps-closed.md - reports/0013-2026-06-07-unibus-admin-panel.md - reports/0014-2026-06-07-unibus-users-http-admin-api.md - reports/0015-2026-06-07-unibus-web-wired.md - ... Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
151 lines
13 KiB
Markdown
151 lines
13 KiB
Markdown
# Report 0018 — unibus: cuentas estilo WhatsApp (alta por invitación + baja por hard-delete)
|
|
|
|
- **Fecha:** 07/06/2026
|
|
- **Autor:** Claude (Opus 4.8)
|
|
- **Ámbito:** bus `unibus` (`pkg/membership`, `pkg/client`) + panel `unibus_admin` (gateway + SPA)
|
|
- **Estado:** done (código + tests + verificación e2e local). Gap de despliegue: el cluster vivo sigue en 0.11.0; el rollout a 0.12.0 lo hace el orquestador.
|
|
|
|
## Resumen
|
|
|
|
Capa de CUENTAS sobre el modelo wallet: la clave privada de cada usuario nace y vive en SU dispositivo; el servidor solo guarda la pública. El admin **crea** usuarios generando un **enlace de invitación** de un solo uso (nunca ve la privada) y **elimina** usuarios con **hard-delete** real (purga del allowlist), manteniendo `revoke` como acción distinta (status flip auditable). El usuario abre el enlace en su cliente, genera ahí su par de claves, y se registra con `POST /register` (autorizado por el token, no por firma admin).
|
|
|
|
Dos repos tocados, ambos en ramas (NINGUNO mergeado a master):
|
|
- Bus `unibus` → rama `quick/user-accounts` (6 commits, pusheada). Bump 0.11.0 → **0.12.0**.
|
|
- Panel `unibus_admin` → rama `quick/user-accounts-ui` (3 commits, pusheada). Bump 0.1.0 → **0.2.0**.
|
|
|
|
## Parte 1 — Bus (`quick/user-accounts`)
|
|
|
|
### Hard-delete
|
|
`Store.DeleteUser(signPub) error` en ambos backends: SQLite `DELETE FROM users` y jetstreamStore `users.Delete(signPubHex)` (con `Get` previo para devolver `ErrNotFound` en un miss, ya que KV `Delete` es idempotente). Borra SOLO el allowlist; las membresías de rooms del ex-usuario quedan **inertes** (ya no puede autenticarse en ningún plano). NO se hace limpieza parcial de membresías — un owner expulsa/rekey su room para forward secrecy. `revoke` se mantiene intacto.
|
|
|
|
Fix de consistencia colateral: el `GetUser` de SQLite ahora mapea `sql.ErrNoRows` → `ErrNotFound` como el KV y como documenta `store.go` ("every lookup miss returns ErrNotFound"). Antes devolvía el error crudo del driver.
|
|
|
|
### Invites (nuevo backend de datos, ambos stores)
|
|
Tipo `Invite{Token, Handle, Role, ExpiresAt, Used, CreatedAt}` + campos de auditoría del consumo (`UsedAt/UsedSignPub/UsedKexPub`, vacíos hasta consumir). Métodos en `Store`:
|
|
- `CreateInvite(handle, role, ttlSecs) (Invite, error)` — token = 32 bytes `crypto/rand` en hex (64 chars); TTL default **7 días** si `ttlSecs <= 0`; role admin|member (vacío → member).
|
|
- `GetInvite(token)`, `ListInvites()`.
|
|
- `ConsumeInvite(token, signPub, kexPub) error` — valida (existe, no usado, no caducado) → registra el `signPub` con el handle/role del invite → marca usado. **Single-use atómico**: SQLite usa una transacción guardada por `used = 0`; KV usa un compare-and-swap sobre la revisión de la entry (mark-first). Burn-on-claim idéntico en ambos: si el `signPub` ya está registrado, devuelve `ErrUserExists` con el invite YA gastado.
|
|
- `CancelInvite(token)` — hard-delete del invite pendiente (para la futura cancelación admin).
|
|
|
|
Persistencia: migración aditiva numerada `003_invites.sql` (raíz + copia embebida byte-idéntica) con tabla `invites` (+ índice `idx_invites_used`); bucket KV nuevo `UNIBUS_invites`. Nada borrado/recreado.
|
|
|
|
### Endpoints HTTP (`pkg/membership/server.go`)
|
|
| Método/ruta | Auth | Qué hace |
|
|
|---|---|---|
|
|
| `POST /invites` | admin-only (`requireAdmin`) | body `{handle, role, ttl_secs?}` → `{token, expires_at}` |
|
|
| `GET /invites` | admin-only | lista de invites **pendientes** (filtra usados/caducados) |
|
|
| `DELETE /invites/{token}` | admin-only | cancela un invite |
|
|
| `DELETE /users/{signpub}` | admin-only | hard-delete (purga) |
|
|
| `POST /register` | **token** (sin firma admin) | redime un invite (ver contrato abajo) |
|
|
|
|
`/register` es la única ruta mutante exenta de firma admin. Se separó `isRateExempt` (solo `/healthz`) de `isAuthExempt` (`/healthz` + `POST /register`): `/register` **salta la firma admin pero sigue sujeto al rate-limit por IP** del control plane (20/s, burst 40). Validación estricta de las dos claves hex (`ValidateSignPubHex` + nuevo `ValidateKexPubHex`, 32 bytes / 64 hex) ANTES de gastar el token; handle y role los fija el invite (sin escalado).
|
|
|
|
### `pkg/client`
|
|
`CreateInvite/ListInvites/CancelInvite/DeleteUser` firmados como admin; `Register(token, signPub, kexPub)` SIN firma vía un nuevo helper `doUnsigned` (failover entre control planes + surfacing del error estructurado del server).
|
|
|
|
### Tests (todos verdes, `CGO_ENABLED=0`)
|
|
- `pkg/membership/invites_test.go` — suite store-level sobre **ambos** backends (golden redeem, single-use, token desconocido, caducado [forzado], cancel, hard-delete) + burn-on-claim.
|
|
- `pkg/membership/invites_http_test.go` — HTTP: crear invite admin → `/register` sin auth 201 → aparece en `/users` → re-register 409 → caducado 410 → keys malformadas 400 → no-admin 403 en las 4 rutas admin → hard-delete purga (vs revoke).
|
|
- `pkg/client/invites_test.go` — e2e cliente: admin acuña invite → joiner NO registrado redime sin firma → aparece → hard-delete desaparece.
|
|
|
|
## CONTRATO de `POST /register` (para el agente del cliente `/join`)
|
|
|
|
La página `/join?token=` del cliente web consume este endpoint. Contrato exacto:
|
|
|
|
**Request** — `POST /register`, `Content-Type: application/json`, **sin** cabeceras de firma:
|
|
```json
|
|
{ "token": "<64-hex>", "sign_pub": "<64-hex Ed25519>", "kex_pub": "<64-hex X25519>" }
|
|
```
|
|
- `token`: el de `?token=` del enlace.
|
|
- `sign_pub`: clave pública Ed25519 generada localmente (identidad / firmas).
|
|
- `kex_pub`: clave pública X25519 generada localmente (key-exchange / sellado de room keys).
|
|
- El cliente genera el par localmente; la privada NUNCA se envía. `handle` y `role` NO van en el body (los fija el invite).
|
|
|
|
**Respuestas:**
|
|
| Código | Cuerpo | Significado |
|
|
|---|---|---|
|
|
| `201` | `{"status":"registered"}` | alta correcta; la identidad ya puede conectarse al bus |
|
|
| `400` | `{"error":"token required"}` / `{"error":"sign-pub must be a 32-byte Ed25519 public key (64 hex chars)…"}` / `…kex-pub…` | body o claves malformadas |
|
|
| `404` | `{"error":"invalid or unknown invite token"}` | token inexistente |
|
|
| `409` | `{"error":"invite already used"}` | token ya consumido (single-use) |
|
|
| `409` | `{"error":"identity already registered"}` | el `sign_pub` ya está en el allowlist |
|
|
| `410` | `{"error":"invite expired"}` | invite caducado |
|
|
| `429` | `{"error":"rate limit exceeded"}` | rate-limit por IP |
|
|
|
|
Notas para el cliente: el endpoint es público pero rate-limited; un token es de un solo uso, así que tras `201` el cliente debe guardar su identidad localmente (cifrada con la contraseña que elija el usuario) — un reintento con el mismo token dará `409`.
|
|
|
|
## Parte 2 — Panel `unibus_admin` (`quick/user-accounts-ui`)
|
|
|
|
### Gateway
|
|
- `POST /api/invites` → `client.CreateInvite`; `GET /api/invites` → `client.ListInvites`; `DELETE /api/users/{pub}` → `client.DeleteUser`. (list/add/revoke existentes intactos.)
|
|
- Doble vía como el resto de users: plano de control firmado (cluster) / store directo (`--db` single-node).
|
|
- **Base URL del enlace**: el gateway construye `<<APP_BASE>>/join?token=XXX` donde `<<APP_BASE>>` es la URL del **cliente final** (la app que hospeda `/join`, NO el panel). Se configura con el flag `--join-base-url https://chat.unibus.example` o la env `UNIBUS_JOIN_BASE_URL`; se expone en `/api/me` (`join_base_url`). Si no se configura, la SPA usa su propio origen como respaldo y avisa.
|
|
|
|
### SPA (Mantine, dark, índigo)
|
|
- Botón **"Crear usuario"** → modal (handle + rol + caducidad en días) → `POST /api/invites` → muestra el **enlace copiable** + alerta de caducidad + aviso si el base URL no está configurado.
|
|
- Card **"Invitaciones pendientes"**: handle, rol, token parcial, caducidad, copiar enlace.
|
|
- Botón **"Eliminar"** por fila → modal de **confirmación FUERTE** que exige teclear el handle y advierte en rojo "BORRADO PERMANENTE" (distinto del revoke, que sigue siendo un `window.confirm`) → `DELETE /api/users/{pub}`.
|
|
- Se conserva "Añadir user (clave conocida)" para el caso avanzado.
|
|
|
|
### Nota de build (revertida)
|
|
Para compilar el panel contra los métodos nuevos del cliente mientras `quick/user-accounts` no está en master, se apuntó temporalmente `go.mod` del panel: `replace github.com/enmanuel/unibus => /tmp/unibus_useraccounts`. **Revertido a `../unibus`** tras verificar (confirmado en el commit). Consecuencia: el panel en `../unibus` no compilará hasta que el bus llegue a master; por eso la rama del panel se mergea DESPUÉS del bus.
|
|
|
|
## Verificación (evidencia ejecutable)
|
|
|
|
### A) Contrato 0.12.0 contra un `membershipd` local (enforce, admin sembrado)
|
|
```
|
|
$ membershipd user add --db … --handle operator --sign-pub <hex> --role admin
|
|
added user "operator" (0ce29047…) role=admin
|
|
# membershipd --bus-auth enforce → /healthz: {"posture":{"enforce":true,"acl":true,…},"status":"ok"}
|
|
|
|
# admin crea invite (firmado) → token 39503d27ae93…
|
|
# POST /register con curl (SIN firma):
|
|
register -> HTTP 201 body: {"status":"registered"}
|
|
# dora aparece en /users: {"Handle":"operator",…} {"Handle":"dora",…} (2 filas)
|
|
# re-register MISMO token:
|
|
re-register -> HTTP 409 body: {"error":"invite already used"}
|
|
# hard-delete de dora → deleted ee64614216ae…
|
|
# /users tras delete: {"Handle":"operator","Role":"admin","Status":"active"} (1 fila — dora purgada)
|
|
|
|
# errores de /register:
|
|
POST /register token inexistente -> {"error":"invalid or unknown invite token"} (HTTP 404)
|
|
POST /register sign_pub "abcd" -> {"error":"sign-pub must be a 32-byte Ed25519 public key (64 hex chars), got 2 bytes"} (HTTP 400)
|
|
```
|
|
|
|
### B) Gateway del panel (`--mock`)
|
|
```
|
|
GET /api/me -> {"users_backend":"sqlite","mock":true,"join_base_url":"https://chat.unibus.example"}
|
|
POST /api/invites -> {"handle":"ana","role":"member","token":"9455207f9098","join_url":"https://chat.unibus.example/join?token=9455207f9098…"}
|
|
GET /api/invites -> [{"handle":"ana","role":"member","join_url":"https://chat.unibus.example/join?token=…"}]
|
|
GET /api/users (antes) -> ["operator","ana","lucas","leo-revoked"]
|
|
DELETE /api/users/{pub} -> {"status":"deleted"} HTTP 200
|
|
GET /api/users (después)-> ["operator","lucas","leo-revoked"] (ana purgada)
|
|
GET / -> HTTP 200 (SPA index) GET /join -> HTTP 200 (fallback SPA)
|
|
```
|
|
|
|
### C) Cluster vivo (sin tocar usuarios reales)
|
|
```
|
|
# Salud (vía SSH, loopback TLS, CA pinada):
|
|
magnus {"posture":{"enforce":true,"acl":true,"tls":true,"cluster":true,"store":"kv"},"status":"ok"}
|
|
homer {"posture":{"enforce":true,"acl":true,"tls":true,"cluster":true,"store":"kv"},"status":"ok"}
|
|
datardos {"posture":{"enforce":true,"acl":true,"tls":true,"cluster":true,"store":"kv"},"status":"ok"}
|
|
|
|
# Confirmación de que /register NO está desplegado aún (rollout 0.12.0 necesario):
|
|
cluster magnus (0.11.0): POST /register -> HTTP 401 (no es ruta pública; pasa por auth y rechaza)
|
|
nodo local (0.12.0): POST /register -> HTTP 400 (ruta pública activa → token required)
|
|
```
|
|
|
|
### Build/test
|
|
- Bus (`/tmp/unibus_useraccounts`): `CGO_ENABLED=0 go build ./...` OK, `go vet ./pkg/...` OK, `go test ./...` OK (todos los paquetes verdes, incluida la suite nueva).
|
|
- Panel (contra el worktree, antes de revertir el replace): `CGO_ENABLED=0 go build ./...` OK, `go vet ./...` OK; SPA `pnpm build` OK (tsc + vite).
|
|
|
|
## Gaps / pendientes
|
|
|
|
- **Rollout del cluster a 0.12.0 (orquestador).** El cluster vivo está en 0.11.0; `/invites`, `/register` y `DELETE /users` no existen allí todavía. La verificación e2e de los endpoints NUEVOS contra el cluster vivo queda pendiente del rollout (verificado contra un nodo local 0.12.0 en su lugar). Confirmado con `/register -> 401` en los 3 nodos.
|
|
- **Orden de merge.** El panel (`quick/user-accounts-ui`) debe mergearse a master DESPUÉS de que el bus (`quick/user-accounts`) llegue a master, porque `go.mod` del panel apunta a `../unibus` y consume los métodos nuevos del cliente.
|
|
- **`kex_pub` validado pero no persistido en el allowlist.** `/register` valida el `kex_pub` (lo necesita el cliente para E2E) y lo guarda como dato de auditoría del consumo del invite (`used_kex_pub`), pero el allowlist de users sigue guardando solo `sign_pub` (igual que antes de este cambio). No hay un directorio global de `kex_pub`; sigue poblándose por-room al invitar a un room (sin cambio). No es regresión.
|
|
- **El cliente `/join` (web/) lo implementa otro agente.** Este trabajo deja `/register` listo y el contrato documentado arriba; `web/` no se tocó.
|
|
- **Cancelar invite desde el panel.** El bus expone `DELETE /invites/{token}` y `client.CancelInvite`, pero la UI del panel solo muestra/copia invites pendientes (no los cancela). Fácil de añadir; fuera del scope pedido.
|
|
- **`go.work` local.** El worktree del bus en `/tmp` usa un `go.work` (gitignored) con `replace fn-registry => /home/enmanuel/fn_registry` para resolver el registry desde `/tmp`. No commiteado.
|
|
- **Migración de invites en el snapshot SQLite→KV.** `importSnapshot/ExportSnapshot` (migración 0003c) no incluye invites; son efímeros (7 días) y de bajo valor, no se migran. Documentado, no bloqueante.
|